@gregiteen/ssss-cli 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  /** Replayable, cursor-aware SSSS projection coordinator and host adapter examples. */
2
2
  import crypto from 'node:crypto';
3
+ import { MemoryEventStore, createCanonicalEvent } from './events.mjs';
3
4
 
4
5
  function stable(value) {
5
6
  if (Array.isArray(value)) return value.map(stable);
@@ -131,3 +132,95 @@ export class ProjectionCoordinator {
131
132
  return { drift: actualHash !== expectedHash, expected_hash: expectedHash, actual_hash: actualHash };
132
133
  }
133
134
  }
135
+
136
+ /**
137
+ * Conformance runner for projection adapters (spec §10, §12).
138
+ * Verifies:
139
+ * 1. Rebuild from event log produces the correct state.
140
+ * 2. Incremental apply equals a full rebuild.
141
+ * 3. Tampered state is reported as drift.
142
+ * 4. Replay is idempotent.
143
+ */
144
+ export async function runProjectionContract(createAdapter = createSqlProjectionAdapter) {
145
+ const checks = [];
146
+ const record = (name, passed) => checks.push({ name, passed: !!passed });
147
+
148
+ const eventStore = new MemoryEventStore();
149
+ const testWorkspace = 'ws-projection-test';
150
+ const principal = { id: 'agent-1', kind: 'agent' };
151
+
152
+ const events = [
153
+ createCanonicalEvent({
154
+ workspace_id: testWorkspace,
155
+ action: 'create',
156
+ subject: 'tasks/task-1.md',
157
+ operation_id: 'op-1',
158
+ idempotency_key: 'key-1',
159
+ principal,
160
+ after_hash: 'sha256:1111111111111111111111111111111111111111111111111111111111111111',
161
+ }),
162
+ createCanonicalEvent({
163
+ workspace_id: testWorkspace,
164
+ action: 'patch',
165
+ subject: 'tasks/task-1.md',
166
+ operation_id: 'op-2',
167
+ idempotency_key: 'key-2',
168
+ principal,
169
+ before_hash: 'sha256:1111111111111111111111111111111111111111111111111111111111111111',
170
+ after_hash: 'sha256:2222222222222222222222222222222222222222222222222222222222222222',
171
+ }),
172
+ createCanonicalEvent({
173
+ workspace_id: testWorkspace,
174
+ action: 'create',
175
+ subject: 'tasks/task-2.md',
176
+ operation_id: 'op-3',
177
+ idempotency_key: 'key-3',
178
+ principal,
179
+ after_hash: 'sha256:3333333333333333333333333333333333333333333333333333333333333333',
180
+ }),
181
+ ];
182
+
183
+ for (const ev of events) {
184
+ await eventStore.append(ev);
185
+ }
186
+
187
+ // 1. Rebuild from event log
188
+ const rebuildAdapter = typeof createAdapter === 'function' ? createAdapter({ table: 'rebuild_test' }) : createAdapter;
189
+ const coordinatorRebuild = new ProjectionCoordinator({ eventStore });
190
+ coordinatorRebuild.register(rebuildAdapter);
191
+
192
+ const rebuildRes = await coordinatorRebuild.replay(rebuildAdapter.id, { rebuild: true, workspaceId: testWorkspace });
193
+ record('rebuild from event log applies all events', rebuildRes.applied === 3);
194
+
195
+ const rebuildSnapshot = await rebuildAdapter.snapshot();
196
+ record('rebuild snapshot contains all projected rows', rebuildSnapshot && Object.keys(rebuildSnapshot).length === 3);
197
+ const rebuildHash = projectionHash(rebuildSnapshot);
198
+
199
+ // 2. Incremental apply equals full rebuild
200
+ const incrementalAdapter = typeof createAdapter === 'function' ? createAdapter({ table: 'incremental_test' }) : createSqlProjectionAdapter({ table: 'incremental_test' });
201
+ const coordinatorIncremental = new ProjectionCoordinator({ eventStore });
202
+ coordinatorIncremental.register(incrementalAdapter);
203
+
204
+ for (const ev of events) {
205
+ await coordinatorIncremental.dispatch(ev);
206
+ }
207
+ const incrementalSnapshot = await incrementalAdapter.snapshot();
208
+ const incrementalHash = projectionHash(incrementalSnapshot);
209
+ record('incremental apply equals a full rebuild', rebuildHash === incrementalHash);
210
+
211
+ // 3. Drift detection
212
+ const cleanDrift = await coordinatorRebuild.detectDrift(rebuildAdapter.id, rebuildHash);
213
+ record('matching state reports no drift', cleanDrift.drift === false);
214
+
215
+ const tamperedDrift = await coordinatorRebuild.detectDrift(rebuildAdapter.id, 'sha256:0000000000000000000000000000000000000000000000000000000000000000');
216
+ record('tampered state is reported as drift', tamperedDrift.drift === true);
217
+
218
+ // 4. Replay idempotency
219
+ const replayAtEnd = await coordinatorRebuild.replay(rebuildAdapter.id, { workspaceId: testWorkspace });
220
+ record('replay at cursor end applies 0 events', replayAtEnd.applied === 0);
221
+
222
+ const snapshotAfterReplay = await rebuildAdapter.snapshot();
223
+ record('replay at cursor end preserves projection state and hash', projectionHash(snapshotAfterReplay) === rebuildHash);
224
+
225
+ return { passed: checks.every((c) => c.passed), checks };
226
+ }
package/src/registry.mjs CHANGED
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url';
16
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
17
  const DEFAULT_REGISTRY_DIR = path.resolve(__dirname, '..', 'registry');
18
18
 
19
- const APPEND_TYPES = new Set(['conversation', 'run']);
19
+ const APPEND_TYPES = new Set(['conversation', 'run', 'thread']);
20
20
  const SAFE_REGISTRY_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
21
21
  const SAFE_TYPE_NAME = /^(?:[a-z][a-z0-9_-]{0,63}:)?[a-z][a-z0-9_-]{0,127}$/;
22
22
  const SAFE_FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
@@ -52,6 +52,13 @@ function validatePrimitiveDefinition(name, def, source, portabilityClasses) {
52
52
  assertStringArray(def.required_fields, `${source}: primitive '${name}' required_fields`);
53
53
  assertStringArray(def.optional_fields, `${source}: primitive '${name}' optional_fields`);
54
54
  assertStringArray(def.immutable_fields, `${source}: primitive '${name}' immutable_fields`);
55
+ assertStringArray(def.aliases, `${source}: primitive '${name}' aliases`);
56
+ if (def.deprecated !== undefined && typeof def.deprecated !== 'boolean') {
57
+ throw new Error(`${source}: primitive '${name}' deprecated must be a boolean.`);
58
+ }
59
+ if (def.deprecation_notice !== undefined && typeof def.deprecation_notice !== 'string') {
60
+ throw new Error(`${source}: primitive '${name}' deprecation_notice must be a string.`);
61
+ }
55
62
 
56
63
  assertRecord(def.required_when, `${source}: primitive '${name}' required_when`);
57
64
  for (const [condition, fields] of Object.entries(def.required_when || {})) {
@@ -157,6 +164,33 @@ export function composeRegistries({ core, extensions: extensionInputs = [] } = {
157
164
  aliases.set(`ssss:${name}`, name);
158
165
  }
159
166
 
167
+ for (const [name, def] of Object.entries(core.document_primitives || {})) {
168
+ for (const alias of def.aliases || []) {
169
+ if (!isPrimitiveTypeName(alias)) {
170
+ throw new Error(`core registry: primitive '${name}' has unsafe alias '${alias}'.`);
171
+ }
172
+ const bareAlias = alias.startsWith('ssss:') ? alias.slice('ssss:'.length) : alias;
173
+ const qualifiedAlias = alias.includes(':') ? alias : `ssss:${alias}`;
174
+ if (bareAlias === name) {
175
+ throw new Error(`core registry: primitive '${name}' cannot alias itself.`);
176
+ }
177
+ if (types.has(bareAlias)) {
178
+ const peer = types.get(bareAlias);
179
+ const peerAliases = (peer.aliases || []).map((a) => (a.startsWith('ssss:') ? a.slice('ssss:'.length) : a));
180
+ if (!peerAliases.includes(name)) {
181
+ throw new Error(`core registry: primitive alias '${alias}' on '${name}' collides with existing primitive '${bareAlias}'.`);
182
+ }
183
+ continue;
184
+ }
185
+ if ((aliases.has(bareAlias) && aliases.get(bareAlias) !== name) ||
186
+ (aliases.has(qualifiedAlias) && aliases.get(qualifiedAlias) !== name)) {
187
+ throw new Error(`core registry: primitive alias '${alias}' collides with an existing primitive.`);
188
+ }
189
+ aliases.set(bareAlias, name);
190
+ aliases.set(qualifiedAlias, name);
191
+ }
192
+ }
193
+
160
194
  for (const [index, item] of extensionInputs.entries()) {
161
195
  const { source, value: ext } = normalizeExtensionInput(item, index);
162
196
  if (!SAFE_REGISTRY_NAME.test(ext.registry || '')) {
@@ -232,6 +266,14 @@ export function composeRegistries({ core, extensions: extensionInputs = [] } = {
232
266
  );
233
267
  }
234
268
  }
269
+ const aliasOverlap = (constraint.allowed_types || []).filter((allowed) =>
270
+ (constraint.disallowed_types || []).some((disallowed) => isSamePrimitiveType(registrySet, allowed, disallowed))
271
+ );
272
+ if (aliasOverlap.length) {
273
+ throw new Error(
274
+ `registry '${def.registry}' primitive '${def.type}' reference '${field}' both allows and forbids (via alias): ${aliasOverlap.join(', ')}.`
275
+ );
276
+ }
235
277
  }
236
278
  }
237
279
 
@@ -297,6 +339,42 @@ export function resolvePrimitiveDefinition(registrySet, type) {
297
339
  return local ? registrySet.types.get(local) || null : null;
298
340
  }
299
341
 
342
+ /**
343
+ * Return all equivalent primitive definitions for a type or definition
344
+ * (the definition itself plus any definitions linked via `aliases`).
345
+ */
346
+ export function resolveEquivalentDefinitions(registrySet, typeOrDef) {
347
+ const base = typeof typeOrDef === 'string'
348
+ ? resolvePrimitiveDefinition(registrySet, typeOrDef)
349
+ : typeOrDef;
350
+ if (!base) return [];
351
+ const seen = new Map([[base.qualified_type || base.type, base]]);
352
+ for (const alias of base.aliases || []) {
353
+ const target = resolvePrimitiveDefinition(registrySet, alias);
354
+ if (target && !seen.has(target.qualified_type || target.type)) {
355
+ seen.set(target.qualified_type || target.type, target);
356
+ }
357
+ }
358
+ return [...seen.values()];
359
+ }
360
+
361
+ /**
362
+ * Return true if two primitive type identifiers resolve to the same canonical
363
+ * primitive definition (either exact match, qualified match, or mutual alias).
364
+ */
365
+ export function isSamePrimitiveType(registrySet, typeA, typeB) {
366
+ if (typeA === typeB) return true;
367
+ if (!typeA || !typeB) return false;
368
+ const defA = resolvePrimitiveDefinition(registrySet, typeA);
369
+ const defB = resolvePrimitiveDefinition(registrySet, typeB);
370
+ if (!defA || !defB) return false;
371
+ if (defA.qualified_type === defB.qualified_type) return true;
372
+ const equivalentsA = resolveEquivalentDefinitions(registrySet, defA);
373
+ if (equivalentsA.some((d) => d.qualified_type === defB.qualified_type)) return true;
374
+ const equivalentsB = resolveEquivalentDefinitions(registrySet, defB);
375
+ return equivalentsB.some((d) => d.qualified_type === defA.qualified_type);
376
+ }
377
+
300
378
  /**
301
379
  * Names of the extensions loaded when a caller does not scope the load.
302
380
  *
@@ -472,14 +550,15 @@ export function validateReferenceConstraints(typeDef, data, resolveReference, ty
472
550
  continue;
473
551
  }
474
552
  const referencedType = referenced.data?.type;
553
+ const registrySurface = { types, aliases: types.aliases };
475
554
  if (!lookupType(referencedType)) {
476
555
  issues.push({ field, issue: `Referenced document declares unknown type '${referencedType || '(missing)'}'.` });
477
556
  continue;
478
557
  }
479
- if (constraint.allowed_types && !constraint.allowed_types.includes(referencedType)) {
558
+ if (constraint.allowed_types && !constraint.allowed_types.some((allowed) => isSamePrimitiveType(registrySurface, allowed, referencedType))) {
480
559
  issues.push({ field, issue: `Referenced type '${referencedType}' is not allowed.` });
481
560
  }
482
- if (constraint.disallowed_types?.includes(referencedType)) {
561
+ if (constraint.disallowed_types?.some((disallowed) => isSamePrimitiveType(registrySurface, disallowed, referencedType))) {
483
562
  issues.push({ field, issue: `Referenced type '${referencedType}' is forbidden.` });
484
563
  }
485
564
  const referencedPortability = resolvePortability(lookupType(referencedType), referenced.data);