Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: CI

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Build and test package
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- name: Checkout rayforce-wasm
uses: actions/checkout@v7

- name: Setup Emscripten
uses: mymindstorm/setup-emsdk@v16
with:
version: '6.0.6'

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
package-manager-cache: false

- name: Build WASM package
run: npm run build

- name: Test runtime, declarations, and documented API
run: npm test

- name: Verify packaged SDK mirrors source
run: |
cmp src/index.js dist/index.js
cmp src/index.d.ts dist/index.d.ts
cmp src/rayforce.sdk.js dist/rayforce.sdk.js
cmp src/rayforce.sdk.d.ts dist/rayforce.sdk.d.ts
cmp src/rayforce.umd.js dist/rayforce.umd.js

- name: Verify npm package payload
run: npm pack --dry-run --ignore-scripts
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ console.log(table.toRows());

```html
<script type="module">
import { init } from 'https://cdn.jsdelivr.net/npm/rayforce-wasm@0.2.0/dist/index.js';
import { init } from 'https://cdn.jsdelivr.net/npm/rayforce-wasm@0.2.1/dist/index.js';
const rf = await init();
const result = rf.eval('(sum (til 100))');
console.log(result.toJS()); // 4950
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ console.log(vec.toJS()); // [100, 2, 3, 4, 5]

```html
<script type="module">
import { init } from 'https://cdn.jsdelivr.net/npm/rayforce-wasm@0.2.0/dist/index.js';
import { init } from 'https://cdn.jsdelivr.net/npm/rayforce-wasm@0.2.1/dist/index.js';

const rf = await init();
console.log(rf.eval('(sum (til 100))').toJS()); // 4950
Expand Down
11 changes: 8 additions & 3 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -987,7 +987,7 @@
<a href="https://rayforcedb.com/" class="nav-brand" title="RayforceDB">
<img src="assets/logo_light_full.svg" alt="RayforceDB" width="120" height="20">
</a>
<span class="badge">SDK v0.2.0</span>
<span class="badge">SDK v0.2.1</span>
<div class="nav-links">
<a href="https://rayforcedb.com/" target="_blank" rel="noopener">Home</a>
<a href="https://rayforcedb.com/docs/index.html" target="_blank" rel="noopener">Docs</a>
Expand Down Expand Up @@ -1512,8 +1512,13 @@ <h3><i data-lucide="table-properties" class="icon"></i> Interactive Table Demo</
score: [95.5, 87.3, 92.1]
});

// Filter using toRows
const rows = table.toRows().filter(r => r.score > 90);
// Execute the same fluent query shown in the example above.
const result = table
.select('name', 'score')
.where(rf.col('score').gt(90))
.execute();
if (result.isError) throw new Error(result.toString());
const rows = result.toRows();

resultDiv.innerHTML = `
<div style="margin-top: 1rem; padding: 1rem; background: var(--code-bg); border-radius: 8px; font-family: var(--font-mono); font-size: 0.85rem;">
Expand Down
2 changes: 1 addition & 1 deletion examples/minimal.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ <h1>⚡ RayforceDB via CDN</h1>

<!-- Load RayforceDB from CDN -->
<script type="module">
import { init } from 'https://unpkg.com/rayforce-wasm@0.2.0/dist/index.js';
import { init } from 'https://unpkg.com/rayforce-wasm@0.2.1/dist/index.js';

window.runDemo = async function() {
const output = document.getElementById('output');
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rayforce-wasm",
"version": "0.2.0",
"version": "0.2.1",
"description": "RayforceDB WASM SDK - High-performance columnar database in WebAssembly",
"type": "module",
"main": "dist/index.js",
Expand All @@ -25,7 +25,7 @@
"scripts": {
"build": "make app",
"build:dev": "make dev",
"test": "node test.mjs",
"test": "node test.mjs && node test-contract.mjs",
"prepublishOnly": "npm run build && npm test",
"serve": "python3 -m http.server 8080"
},
Expand Down
2 changes: 1 addition & 1 deletion src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {

export * from './rayforce.sdk.js';

export declare const version: '0.2.0';
export declare const version: '0.2.1';

export interface InitOptions {
/** URL/path to the Emscripten module loader. Defaults to ./rayforce.js. */
Expand Down
6 changes: 3 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,19 @@
*
* Usage (browser ES module):
* <script type="module">
* import { init } from 'https://cdn.jsdelivr.net/npm/rayforce-wasm@0.2.0/dist/index.js';
* import { init } from 'https://cdn.jsdelivr.net/npm/rayforce-wasm@0.2.1/dist/index.js';
* const rf = await init();
* console.log(rf.eval('(+ 1 2)').toJS());
* </script>
*
* @module rayforce
* @version 0.2.0
* @version 0.2.1
*/

import { createRayforceSDK, Types, Expr } from './rayforce.sdk.js';

// SDK version
export const version = '0.2.0';
export const version = '0.2.1';

// Re-export types and utilities
export { Types, Expr };
Expand Down
41 changes: 37 additions & 4 deletions src/rayforce.sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Provides TypedArray views over native Rayforce vectors for efficient data access.
*
* @module rayforce
* @version 0.2.0
* @version 0.2.1
*/

// ============================================================================
Expand Down Expand Up @@ -643,6 +643,15 @@ class RayforceSDK {
typeName(typeCode) {
return this._getTypeName(typeCode);
}

/**
* Create a column reference for the query builder.
* @param {string} name
* @returns {Expr}
*/
col(name) {
return Expr.col(this, name);
}
}

// ============================================================================
Expand Down Expand Up @@ -1458,6 +1467,17 @@ class Lambda extends RayObject {
// Query Builder
// ============================================================================

const RAYFALL_NAME = /^[A-Za-z0-9_.-]+$/;

function assertRayfallName(name, role = 'column') {
if (typeof name !== 'string' || name.length === 0 || !RAYFALL_NAME.test(name)) {
throw new TypeError(
`${role} name must contain only letters, numbers, underscores, dots, or hyphens`,
);
}
return name;
}

/**
* Expression builder for query conditions
*/
Expand All @@ -1473,12 +1493,14 @@ class Expr {
* @returns {Expr}
*/
static col(sdk, name) {
return new Expr(sdk, [`\`${name}`]);
// Rayforce v2 uses a leading apostrophe for a quoted symbol. Inside a
// query, quoted symbols resolve to columns just like bare names.
return new Expr(sdk, [`'${assertRayfallName(name)}`]);
}

// Comparison operators
eq(value) { return this._binOp('=', value); }
ne(value) { return this._binOp('<>', value); }
eq(value) { return this._binOp('==', value); }
ne(value) { return this._binOp('!=', value); }
lt(value) { return this._binOp('<', value); }
le(value) { return this._binOp('<=', value); }
gt(value) { return this._binOp('>', value); }
Expand All @@ -1505,6 +1527,9 @@ class Expr {
}

_logicOp(op, other) {
if (!(other instanceof Expr)) {
throw new TypeError(`${op}() expects an Expr`);
}
return new Expr(this._sdk, [`(${op}`, ...this._parts, ...other._parts, ')']);
}

Expand Down Expand Up @@ -1542,6 +1567,10 @@ class SelectQuery {
* @returns {SelectQuery}
*/
select(...cols) {
for (const col of cols) {
if (typeof col === 'string') assertRayfallName(col);
else if (!(col instanceof Expr)) throw new TypeError('select() expects column names or Expr values');
}
const q = this._clone();
q._selectCols = cols;
return q;
Expand All @@ -1554,6 +1583,8 @@ class SelectQuery {
* @returns {SelectQuery}
*/
withColumn(name, expr) {
assertRayfallName(name, 'output column');
if (!(expr instanceof Expr)) throw new TypeError('withColumn() expects an Expr');
const q = this._clone();
q._computedCols[name] = expr;
return q;
Expand All @@ -1565,6 +1596,7 @@ class SelectQuery {
* @returns {SelectQuery}
*/
where(condition) {
if (!(condition instanceof Expr)) throw new TypeError('where() expects an Expr');
const q = this._clone();
q._whereCond = q._whereCond ? q._whereCond.and(condition) : condition;
return q;
Expand All @@ -1576,6 +1608,7 @@ class SelectQuery {
* @returns {SelectQuery}
*/
groupBy(...cols) {
for (const col of cols) assertRayfallName(col, 'group-by column');
const q = this._clone();
q._byCols = cols;
return q;
Expand Down
128 changes: 128 additions & 0 deletions test-contract.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as indexExports from './dist/index.js';
import * as sdkExports from './dist/rayforce.sdk.js';
import { init, Types } from './dist/index.js';

const declarations = readFileSync(new URL('./dist/rayforce.sdk.d.ts', import.meta.url), 'utf8');
const indexDeclarations = readFileSync(new URL('./dist/index.d.ts', import.meta.url), 'utf8');
const readme = readFileSync(new URL('./README.md', import.meta.url), 'utf8');

function declarationValueExports(source) {
const names = new Set();
for (const match of source.matchAll(/^export declare (?:class|const|function) ([A-Za-z_$][\w$]*)/gm)) {
names.add(match[1]);
}
for (const match of source.matchAll(/^export \{[^\n}]*\bas\s+([A-Za-z_$][\w$]*)[^\n}]*\};/gm)) {
names.add(match[1]);
}
if (/^export default /m.test(source)) names.add('default');
return names;
}

const declaredSdkExports = declarationValueExports(declarations);
assert.deepEqual(
Object.keys(sdkExports).sort(),
[...declaredSdkExports].sort(),
'rayforce.sdk.js exports do not match rayforce.sdk.d.ts',
);

const declaredIndexExports = declarationValueExports(indexDeclarations);
for (const name of declaredSdkExports) {
if (name !== 'default') declaredIndexExports.add(name);
}
assert.deepEqual(
Object.keys(indexExports).sort(),
[...declaredIndexExports].sort(),
'index.js exports do not match index.d.ts',
);

function declarationClasses(source) {
const classes = new Map();
const classPattern = /export declare class ([A-Za-z_$][\w$]*)(?: extends ([A-Za-z_$][\w$]*))? \{([\s\S]*?)^\}/gm;

for (const match of source.matchAll(classPattern)) {
const [, name, extendsName, body] = match;
const instance = new Set();
const statics = new Set();
const memberPattern = /^\s+(static\s+)?(?:readonly\s+)?([A-Za-z_$][\w$]*|\[Symbol\.iterator\])(?:<[^\n>]+>)?\s*(?=\(|:)/gm;

for (const member of body.matchAll(memberPattern)) {
const memberName = member[2];
if (memberName === 'constructor') continue;
(member[1] ? statics : instance).add(memberName);
}
classes.set(name, { extendsName, instance, statics });
}

return classes;
}

function runtimeKey(name) {
return name === '[Symbol.iterator]' ? globalThis.Symbol.iterator : name;
}

const declaredClasses = declarationClasses(declarations);
assert.ok(declaredClasses.size > 0, 'no TypeScript class declarations were found');

function hasDeclaredInstanceMember(className, member) {
let current = declaredClasses.get(className);
while (current) {
if (current.instance.has(member)) return true;
current = current.extendsName ? declaredClasses.get(current.extendsName) : null;
}
return false;
}

for (const [className, members] of declaredClasses) {
const RuntimeClass = sdkExports[className];
assert.equal(typeof RuntimeClass, 'function', `${className} is declared but not exported at runtime`);

for (const member of members.instance) {
assert.ok(
runtimeKey(member) in RuntimeClass.prototype,
`${className}.${member} is declared in TypeScript but missing at runtime`,
);
}
for (const member of members.statics) {
assert.ok(
runtimeKey(member) in RuntimeClass,
`${className}.${member} is declared static in TypeScript but missing at runtime`,
);
}

for (const key of Reflect.ownKeys(RuntimeClass.prototype)) {
if (key === 'constructor' || (typeof key === 'string' && key.startsWith('_'))) continue;
const declaredName = key === globalThis.Symbol.iterator ? '[Symbol.iterator]' : key;
assert.ok(
hasDeclaredInstanceMember(className, declaredName),
`${className}.${String(declaredName)} exists at runtime but is missing from TypeScript`,
);
}
}

function documentedMembers(receiver) {
const names = new Set();
const pattern = new RegExp(`\\b${receiver}\\.([A-Za-z_$][\\w$]*)\\b`, 'g');
for (const match of readme.matchAll(pattern)) names.add(match[1]);
return names;
}

const rf = await init({ singleton: false });
const table = rf.table({ id: [1], name: ['Ada'], score: [95.5] });
const vec = rf.vector(Types.F64, [1.5]);
const col = rf.col('score');
const result = rf.eval('(+ 1 2)');
const row = table.row(0);

for (const [receiver, object] of Object.entries({ rf, table, vec, col, result, row })) {
const members = documentedMembers(receiver);
assert.ok(members.size > 0, `README contract receiver ${receiver} has no documented members`);
for (const member of members) {
assert.ok(member in object, `README documents ${receiver}.${member}, but it is missing at runtime`);
}
}

console.log(
`${declaredClasses.size} TypeScript classes, module exports, and README API usages match the runtime`,
);
Loading