@gregiteen/ssss-cli 0.8.0 → 0.9.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +55 -4
  2. package/README.md +42 -84
  3. package/VERSION +1 -1
  4. package/conformance/README.md +1 -1
  5. package/conformance/fixtures.json +4 -48
  6. package/conformance/reference-bundle.ucw.json +22 -21
  7. package/docs/help/bundle.md +2 -2
  8. package/docs/help/overview.md +9 -6
  9. package/docs/help/semantic.md +5 -4
  10. package/docs/ssss-spec.md +42 -86
  11. package/package.json +22 -3
  12. package/registry/core.json +22 -29
  13. package/scripts/build-reference-bundle.mjs +0 -20
  14. package/scripts/cmd-adapter.mjs +29 -0
  15. package/scripts/cmd-import.mjs +1 -1
  16. package/scripts/cmd-migrate.mjs +26 -0
  17. package/scripts/cmd-new.mjs +5 -5
  18. package/scripts/cmd-primitive.mjs +48 -0
  19. package/scripts/cmd-registry.mjs +25 -0
  20. package/scripts/cmd-semantic.mjs +6 -8
  21. package/scripts/conformance-09.mjs +331 -0
  22. package/scripts/conformance.mjs +153 -186
  23. package/scripts/generate-sbom.mjs +96 -0
  24. package/scripts/phase9-cross-host-corpus.mjs +223 -0
  25. package/scripts/ssss.mjs +8 -2
  26. package/skills/ssss/SKILL.md +32 -208
  27. package/skills/ssss/evals/evals.json +3 -3
  28. package/skills/ssss/references/contract-reference.md +7 -8
  29. package/skills/ssss/scripts/validate-schema.mjs +1 -44
  30. package/src/authorization.mjs +62 -0
  31. package/src/bundle.mjs +41 -5
  32. package/src/engine.mjs +101 -503
  33. package/src/events.mjs +155 -0
  34. package/src/guard.mjs +32 -0
  35. package/src/http.mjs +26 -0
  36. package/src/idempotency.mjs +55 -0
  37. package/src/kernel.mjs +260 -0
  38. package/src/leases.mjs +173 -0
  39. package/src/primitive.mjs +227 -0
  40. package/src/projections.mjs +133 -0
  41. package/src/registry.mjs +190 -39
  42. package/src/semantic.mjs +124 -316
  43. package/src/ui.mjs +100 -0
  44. package/src/validator.mjs +87 -0
  45. package/src/vfs.mjs +203 -0
  46. package/types/kernel.d.ts +48 -0
  47. package/docs/help/localization.md +0 -43
  48. package/scripts/cmd-localize.mjs +0 -44
package/src/leases.mjs ADDED
@@ -0,0 +1,173 @@
1
+ /** Standard lease store semantics. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+
6
+ function validateLeaseInput(value) {
7
+ for (const field of ['workspace_id', 'target', 'principal_id', 'operation_id']) {
8
+ if (typeof value?.[field] !== 'string' || !value[field]) throw new Error(`Lease ${field} is required.`);
9
+ }
10
+ }
11
+
12
+ export class MemoryLeaseStore {
13
+ #leases = new Map();
14
+ constructor({ clock = () => Date.now() } = {}) { this.clock = clock; }
15
+ #key(workspaceId, target) { return JSON.stringify([workspaceId, target]); }
16
+ async acquire(input, ttlMs = 30_000) {
17
+ validateLeaseInput(input);
18
+ const key = this.#key(input.workspace_id, input.target);
19
+ const existing = this.#leases.get(key);
20
+ if (existing && existing.expires_at > this.clock()) throw new Error('Lease conflict.');
21
+ const lease = { ...input, lease_id: input.lease_id || crypto.randomUUID(), issued_at: this.clock(), expires_at: this.clock() + ttlMs };
22
+ this.#leases.set(key, lease);
23
+ return { ...lease };
24
+ }
25
+ async verify(input) {
26
+ const lease = this.#leases.get(this.#key(input.workspace_id, input.target));
27
+ if (!lease) return { valid: false, reason: 'Required lease is missing.' };
28
+ if (lease.expires_at <= this.clock()) return { valid: false, reason: 'Lease has expired.' };
29
+ for (const field of ['lease_id', 'principal_id', 'operation_id']) {
30
+ if (lease[field] !== input[field]) return { valid: false, reason: `Lease ${field} mismatch.` };
31
+ }
32
+ return { valid: true, lease: { ...lease } };
33
+ }
34
+ async renew(input, ttlMs = 30_000) {
35
+ const verified = await this.verify(input);
36
+ if (!verified.valid) throw new Error(verified.reason);
37
+ verified.lease.expires_at = this.clock() + ttlMs;
38
+ this.#leases.set(this.#key(input.workspace_id, input.target), verified.lease);
39
+ return { ...verified.lease };
40
+ }
41
+ async release(input) {
42
+ const verified = await this.verify(input);
43
+ if (!verified.valid) throw new Error(verified.reason);
44
+ this.#leases.delete(this.#key(input.workspace_id, input.target));
45
+ return { released: true, lease_id: input.lease_id };
46
+ }
47
+ }
48
+
49
+ export function leaseStatePath(root, workspaceId, target) {
50
+ const hash = crypto.createHash('sha256').update(`${workspaceId}\0${target}`).digest('hex');
51
+ return path.join(root, `${hash}.lease.json`);
52
+ }
53
+
54
+ export class FileLeaseStore {
55
+ constructor(root, options = {}) {
56
+ fs.mkdirSync(root, { recursive: true });
57
+ this.root = fs.realpathSync(root);
58
+ this.clock = options.clock || (() => Date.now());
59
+ }
60
+ #file(workspaceId, target) {
61
+ return leaseStatePath(this.root, workspaceId, target);
62
+ }
63
+ #load(workspaceId, target) {
64
+ const file = this.#file(workspaceId, target);
65
+ if (!fs.existsSync(file)) return null;
66
+ const stat = fs.lstatSync(file);
67
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('Lease state is unreadable.');
68
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
69
+ catch { throw new Error('Lease state is unreadable.'); }
70
+ }
71
+ #write(lease) {
72
+ const file = this.#file(lease.workspace_id, lease.target);
73
+ const temp = `${file}.${crypto.randomUUID()}.tmp`;
74
+ try {
75
+ fs.writeFileSync(temp, `${JSON.stringify(lease)}\n`, { flag: 'wx', mode: 0o600 });
76
+ fs.renameSync(temp, file);
77
+ } finally { fs.rmSync(temp, { force: true }); }
78
+ }
79
+ async acquire(input, ttlMs = 30_000) {
80
+ validateLeaseInput(input);
81
+ const existing = this.#load(input.workspace_id, input.target);
82
+ if (existing && existing.expires_at > this.clock()) throw new Error('Lease conflict.');
83
+ const lease = { ...input, lease_id: input.lease_id || crypto.randomUUID(), issued_at: this.clock(), expires_at: this.clock() + ttlMs };
84
+ this.#write(lease);
85
+ return { ...lease };
86
+ }
87
+ async verify(input) {
88
+ const lease = this.#load(input.workspace_id, input.target);
89
+ if (!lease) return { valid: false, reason: 'Required lease is missing.' };
90
+ if (lease.expires_at <= this.clock()) return { valid: false, reason: 'Lease has expired.' };
91
+ for (const field of ['lease_id', 'principal_id', 'operation_id']) {
92
+ if (lease[field] !== input[field]) return { valid: false, reason: `Lease ${field} mismatch.` };
93
+ }
94
+ return { valid: true, lease };
95
+ }
96
+ async renew(input, ttlMs = 30_000) {
97
+ const verified = await this.verify(input);
98
+ if (!verified.valid) throw new Error(verified.reason);
99
+ verified.lease.expires_at = this.clock() + ttlMs;
100
+ this.#write(verified.lease);
101
+ return verified.lease;
102
+ }
103
+ async release(input) {
104
+ const verified = await this.verify(input);
105
+ if (!verified.valid) throw new Error(verified.reason);
106
+ fs.rmSync(this.#file(input.workspace_id, input.target), { force: true });
107
+ return { released: true, lease_id: input.lease_id };
108
+ }
109
+ }
110
+
111
+ export async function runLeaseContract(store, { clock } = {}) {
112
+ const input = {
113
+ workspace_id: `ws-${crypto.randomUUID()}`,
114
+ target: `rules/${crypto.randomUUID()}.md`,
115
+ principal_id: 'principal',
116
+ operation_id: 'operation',
117
+ };
118
+ const checks = [];
119
+ const record = (name, passed) => checks.push({ name, passed: !!passed });
120
+ const acquired = await store.acquire(input, 10_000);
121
+ record('acquire returns bound lease', acquired.lease_id && acquired.principal_id === input.principal_id);
122
+ record('matching lease verifies', (await store.verify({ ...input, lease_id: acquired.lease_id })).valid);
123
+ record('mismatched owner fails closed', !(await store.verify({ ...input, principal_id: 'other', lease_id: acquired.lease_id })).valid);
124
+ record('mismatched lease_id fails closed', !(await store.verify({ ...input, lease_id: crypto.randomUUID() })).valid);
125
+ record('missing lease fails closed', !(await store.verify({
126
+ workspace_id: input.workspace_id,
127
+ target: `missing/${crypto.randomUUID()}.md`,
128
+ principal_id: input.principal_id,
129
+ operation_id: input.operation_id,
130
+ lease_id: acquired.lease_id,
131
+ })).valid);
132
+ const renewed = await store.renew({ ...input, lease_id: acquired.lease_id }, 20_000);
133
+ record('renew extends expiry', renewed.expires_at >= acquired.expires_at);
134
+ await store.release({ ...input, lease_id: acquired.lease_id });
135
+ record('released lease is missing', !(await store.verify({ ...input, lease_id: acquired.lease_id })).valid);
136
+
137
+ // Controllable clock proves expiry fail-closed for any store that accepts injection.
138
+ if (typeof store.clock === 'function' || clock) {
139
+ let now = 1_000_000;
140
+ const timed = store.constructor === MemoryLeaseStore
141
+ ? new MemoryLeaseStore({ clock: () => now })
142
+ : store.constructor === FileLeaseStore
143
+ ? new FileLeaseStore(path.join(store.root, `expiry-${crypto.randomUUID()}`), { clock: () => now })
144
+ : null;
145
+ if (timed) {
146
+ const timedInput = { ...input, target: `rules/expiry-${crypto.randomUUID()}.md` };
147
+ const short = await timed.acquire(timedInput, 50);
148
+ now = short.expires_at + 1;
149
+ record('expired lease fails closed', !(await timed.verify({ ...timedInput, lease_id: short.lease_id })).valid);
150
+ }
151
+ }
152
+
153
+ // Filesystem stores must fail closed on corrupt/unreadable state.
154
+ if (store instanceof FileLeaseStore) {
155
+ const corruptInput = {
156
+ workspace_id: `ws-${crypto.randomUUID()}`,
157
+ target: `rules/corrupt-${crypto.randomUUID()}.md`,
158
+ principal_id: 'principal',
159
+ operation_id: 'operation',
160
+ };
161
+ const good = await store.acquire(corruptInput, 30_000);
162
+ fs.writeFileSync(leaseStatePath(store.root, corruptInput.workspace_id, corruptInput.target), '{not-json', { mode: 0o600 });
163
+ let unreadable = false;
164
+ try {
165
+ await store.verify({ ...corruptInput, lease_id: good.lease_id });
166
+ } catch (error) {
167
+ unreadable = /unreadable/i.test(error.message);
168
+ }
169
+ record('unreadable lease state fails closed', unreadable);
170
+ }
171
+
172
+ return { passed: checks.every((check) => check.passed), checks };
173
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * SSSS primitive-definition API (spec 0.9).
3
+ *
4
+ * Human-facing names and descriptions may be authored in any language. Stable
5
+ * primitive/field/action/capability identifiers are symbolic controls and are
6
+ * generated or validated here rather than translated by presentation layers.
7
+ */
8
+ import crypto from 'node:crypto';
9
+ import { isPrimitiveTypeName } from './registry.mjs';
10
+
11
+ export const PRIMITIVE_MUTATIONS = ['replace', 'append'];
12
+ export const PRIMITIVE_PORTABILITY = ['structural', 'tenant_private', 'resource_bound'];
13
+ export const PRIMITIVE_SCOPES = ['system', 'account', 'workspace', 'user'];
14
+ export const FIELD_KINDS = [
15
+ 'string', 'text', 'number', 'integer', 'boolean', 'datetime', 'date',
16
+ 'enum', 'object', 'array', 'reference', 'secret_reference', 'resource_reference',
17
+ ];
18
+
19
+ const NAMESPACE_RE = /^[a-z][a-z0-9_-]{0,63}$/;
20
+ const SYMBOL_RE = /^[a-z][a-z0-9_-]{0,127}$/;
21
+ const LANGUAGE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
22
+ const CAPABILITY_RE = /^[a-z][a-z0-9_.-]{0,63}:[a-z][a-z0-9_.-]{0,127}$/;
23
+
24
+ function isRecord(value) {
25
+ return !!value && typeof value === 'object' && !Array.isArray(value);
26
+ }
27
+
28
+ function issue(field, message) {
29
+ return { field, issue: message };
30
+ }
31
+
32
+ function uniqueStrings(value) {
33
+ return Array.isArray(value) && value.every((item) => typeof item === 'string' && item) &&
34
+ new Set(value).size === value.length;
35
+ }
36
+
37
+ /** Generate a stable opaque local ID from an authored name in any language. */
38
+ export function generatePrimitiveId({ namespace, name, salt = '' }) {
39
+ if (!NAMESPACE_RE.test(namespace || '')) throw new Error('namespace must be a safe lowercase identifier.');
40
+ if (typeof name !== 'string' || !name.trim()) throw new Error('name must be non-empty.');
41
+ const digest = crypto.createHash('sha256')
42
+ .update(`${namespace}\0${name.normalize('NFKC')}\0${salt}`)
43
+ .digest('hex')
44
+ .slice(0, 20);
45
+ return `${namespace}:p_${digest}`;
46
+ }
47
+
48
+ export function validatePrimitiveDefinition(definition) {
49
+ const errors = [];
50
+ if (!isRecord(definition)) return { valid: false, errors: [issue('(root)', 'Primitive definition must be an object.')] };
51
+ const required = ['primitive_id', 'namespace', 'version', 'name', 'mutation', 'portability', 'scopes', 'fields'];
52
+ for (const field of required) {
53
+ const value = definition[field];
54
+ if (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)) {
55
+ errors.push(issue(field, `Missing required field '${field}'.`));
56
+ }
57
+ }
58
+ if (!NAMESPACE_RE.test(definition.namespace || '')) {
59
+ errors.push(issue('namespace', 'Use a lowercase namespace containing letters, numbers, underscore, or hyphen.'));
60
+ }
61
+ if (!isPrimitiveTypeName(definition.primitive_id) ||
62
+ !definition.primitive_id?.startsWith(`${definition.namespace}:`)) {
63
+ errors.push(issue('primitive_id', 'Use a qualified identifier whose prefix matches namespace.'));
64
+ }
65
+ if (!Number.isInteger(definition.version) || definition.version < 1) {
66
+ errors.push(issue('version', 'Version must be a positive integer.'));
67
+ }
68
+ if (definition.revision !== undefined && (!Number.isInteger(definition.revision) || definition.revision < 1)) {
69
+ errors.push(issue('revision', 'Revision must be a positive integer.'));
70
+ }
71
+ if (typeof definition.name !== 'string' || !definition.name.trim()) {
72
+ errors.push(issue('name', 'Name may use any language but must not be empty.'));
73
+ }
74
+ if (definition.language !== undefined && !LANGUAGE_RE.test(definition.language)) {
75
+ errors.push(issue('language', 'Use a BCP-47-style language tag.'));
76
+ }
77
+ if (!PRIMITIVE_MUTATIONS.includes(definition.mutation)) {
78
+ errors.push(issue('mutation', `Use one of: ${PRIMITIVE_MUTATIONS.join(', ')}.`));
79
+ }
80
+ if (!PRIMITIVE_PORTABILITY.includes(definition.portability)) {
81
+ errors.push(issue('portability', `Use one of: ${PRIMITIVE_PORTABILITY.join(', ')}.`));
82
+ }
83
+ if (!uniqueStrings(definition.scopes) ||
84
+ definition.scopes.some((scope) => !PRIMITIVE_SCOPES.includes(scope))) {
85
+ errors.push(issue('scopes', `Provide a unique non-empty subset of: ${PRIMITIVE_SCOPES.join(', ')}.`));
86
+ }
87
+ if (!Array.isArray(definition.fields)) {
88
+ errors.push(issue('fields', 'Fields must be an array.'));
89
+ } else {
90
+ const ids = new Set();
91
+ for (const [index, field] of definition.fields.entries()) {
92
+ const prefix = `fields[${index}]`;
93
+ if (!isRecord(field)) {
94
+ errors.push(issue(prefix, 'Field definition must be an object.'));
95
+ continue;
96
+ }
97
+ if (!SYMBOL_RE.test(field.id || '')) errors.push(issue(`${prefix}.id`, 'Use a stable lowercase symbolic field ID.'));
98
+ else if (ids.has(field.id)) errors.push(issue(`${prefix}.id`, `Duplicate field ID '${field.id}'.`));
99
+ else ids.add(field.id);
100
+ if (typeof field.name !== 'string' || !field.name.trim()) {
101
+ errors.push(issue(`${prefix}.name`, 'Field name may use any language but must not be empty.'));
102
+ }
103
+ if (!FIELD_KINDS.includes(field.kind)) {
104
+ errors.push(issue(`${prefix}.kind`, `Use one of: ${FIELD_KINDS.join(', ')}.`));
105
+ }
106
+ if (field.kind === 'enum' && !uniqueStrings(field.values)) {
107
+ errors.push(issue(`${prefix}.values`, 'Enum fields require unique non-empty symbolic values.'));
108
+ }
109
+ if (field.pattern !== undefined) {
110
+ try { new RegExp(field.pattern, 'u'); }
111
+ catch (error) { errors.push(issue(`${prefix}.pattern`, `Invalid regular expression: ${error.message}`)); }
112
+ }
113
+ }
114
+ }
115
+ if (definition.capabilities !== undefined) {
116
+ if (!isRecord(definition.capabilities)) errors.push(issue('capabilities', 'Capabilities must be an action-to-list object.'));
117
+ else for (const [action, values] of Object.entries(definition.capabilities)) {
118
+ if (!SYMBOL_RE.test(action) || !uniqueStrings(values) || values.some((value) => !CAPABILITY_RE.test(value))) {
119
+ errors.push(issue(`capabilities.${action}`, 'Use unique qualified capability IDs.'));
120
+ }
121
+ }
122
+ }
123
+ if (definition.projections !== undefined && !Array.isArray(definition.projections)) {
124
+ errors.push(issue('projections', 'Projections must be an array.'));
125
+ }
126
+ if (definition.aliases !== undefined && (!uniqueStrings(definition.aliases) ||
127
+ definition.aliases.some((alias) => !isPrimitiveTypeName(alias)))) {
128
+ errors.push(issue('aliases', 'Aliases must be unique safe primitive identifiers.'));
129
+ }
130
+ if (definition.migrations !== undefined && !Array.isArray(definition.migrations)) {
131
+ errors.push(issue('migrations', 'Migrations must be an array.'));
132
+ }
133
+ return { valid: errors.length === 0, errors };
134
+ }
135
+
136
+ /**
137
+ * Normalize a user/repo-authored definition and generate missing symbolic IDs.
138
+ */
139
+ export function definePrimitive(input, options = {}) {
140
+ if (!isRecord(input)) throw new Error('Primitive input must be an object.');
141
+ const namespace = input.namespace || options.namespace;
142
+ const primitive_id = input.primitive_id || generatePrimitiveId({ namespace, name: input.name, salt: options.salt });
143
+ const fields = (input.fields || []).map((field, index) => ({
144
+ ...field,
145
+ id: field.id || `field_${crypto.createHash('sha256')
146
+ .update(`${primitive_id}\0${field.name || index}`)
147
+ .digest('hex').slice(0, 12)}`,
148
+ }));
149
+ const definition = {
150
+ type: 'ssss:primitive',
151
+ version: 1,
152
+ revision: 1,
153
+ mutation: 'replace',
154
+ portability: 'structural',
155
+ scopes: ['workspace'],
156
+ ...input,
157
+ namespace,
158
+ primitive_id,
159
+ fields,
160
+ };
161
+ const result = validatePrimitiveDefinition(definition);
162
+ if (!result.valid) {
163
+ const error = new Error(result.errors.map((entry) => `${entry.field}: ${entry.issue}`).join('; '));
164
+ error.field_errors = result.errors;
165
+ throw error;
166
+ }
167
+ return definition;
168
+ }
169
+
170
+ /** Convert a rich primitive definition into the executable registry schema. */
171
+ export function primitiveDefinitionToRegistryEntry(definition) {
172
+ const result = validatePrimitiveDefinition(definition);
173
+ if (!result.valid) throw new Error(result.errors.map((entry) => `${entry.field}: ${entry.issue}`).join('; '));
174
+ const required_fields = ['type'];
175
+ const optional_fields = [];
176
+ const enums = {};
177
+ const patterns = {};
178
+ const references = {};
179
+ for (const field of definition.fields) {
180
+ (field.required ? required_fields : optional_fields).push(field.id);
181
+ if (field.kind === 'enum') enums[field.id] = field.values;
182
+ if (field.pattern) patterns[field.id] = field.pattern;
183
+ if (field.kind === 'reference') references[field.id] = {
184
+ must_exist: field.must_exist !== false,
185
+ ...(field.allowed_types ? { allowed_types: field.allowed_types } : {}),
186
+ ...(field.allowed_portability ? { allowed_portability: field.allowed_portability } : {}),
187
+ };
188
+ }
189
+ return {
190
+ family: definition.family || 'extension',
191
+ canonical_path: definition.path_template || null,
192
+ append_only: definition.mutation === 'append',
193
+ portability: definition.portability,
194
+ required_fields,
195
+ optional_fields,
196
+ ...(Object.keys(enums).length ? { enums } : {}),
197
+ ...(Object.keys(patterns).length ? { patterns } : {}),
198
+ ...(Object.keys(references).length ? { references } : {}),
199
+ scopes: definition.scopes,
200
+ capabilities: definition.capabilities || {},
201
+ events: definition.events || [],
202
+ projections: definition.projections || [],
203
+ ui: definition.ui || {},
204
+ aliases: definition.aliases || [],
205
+ migrations: definition.migrations || [],
206
+ primitive_version: definition.version,
207
+ primitive_revision: definition.revision || 1,
208
+ authored_language: definition.language || null,
209
+ };
210
+ }
211
+
212
+ /** Package one or more definitions as an extension registry object. */
213
+ export function definitionsToExtensionRegistry(namespace, definitions, options = {}) {
214
+ if (!NAMESPACE_RE.test(namespace || '')) throw new Error('Invalid namespace.');
215
+ const document_primitives = {};
216
+ for (const definition of definitions) {
217
+ if (definition.namespace !== namespace) throw new Error(`Definition '${definition.primitive_id}' belongs to another namespace.`);
218
+ document_primitives[definition.primitive_id] = primitiveDefinitionToRegistryEntry(definition);
219
+ }
220
+ return {
221
+ registry: namespace,
222
+ extends: 'ssss',
223
+ version: options.version || '1.0.0',
224
+ requires: options.requires || {},
225
+ document_primitives,
226
+ };
227
+ }
@@ -0,0 +1,133 @@
1
+ /** Replayable, cursor-aware SSSS projection coordinator and host adapter examples. */
2
+ import crypto from 'node:crypto';
3
+
4
+ function stable(value) {
5
+ if (Array.isArray(value)) return value.map(stable);
6
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
7
+ return value;
8
+ }
9
+
10
+ export function projectionHash(value) {
11
+ return `sha256:${crypto.createHash('sha256').update(JSON.stringify(stable(value))).digest('hex')}`;
12
+ }
13
+
14
+ /**
15
+ * Reference projection destination adapters.
16
+ * Hosts own durable destination writes; these prove the shared apply/reset/snapshot contract.
17
+ */
18
+ export function createSqlProjectionAdapter({ table = 'ssss_events' } = {}) {
19
+ const rows = new Map();
20
+ return {
21
+ id: `sql:${table}`,
22
+ kind: 'sql',
23
+ apply: async (event) => {
24
+ rows.set(event.event_id, {
25
+ event_id: event.event_id,
26
+ subject: event.subject,
27
+ action: event.action,
28
+ after_hash: event.after_hash,
29
+ workspace_id: event.workspace_id,
30
+ });
31
+ },
32
+ reset: async () => rows.clear(),
33
+ snapshot: async () => Object.fromEntries([...rows.entries()].sort()),
34
+ };
35
+ }
36
+
37
+ export function createSearchProjectionAdapter({ index = 'ssss' } = {}) {
38
+ const documents = new Map();
39
+ return {
40
+ id: `search:${index}`,
41
+ kind: 'search',
42
+ apply: async (event) => {
43
+ documents.set(event.subject, {
44
+ subject: event.subject,
45
+ terms: [event.action, event.primitive_id, event.subject].filter(Boolean).join(' '),
46
+ after_hash: event.after_hash,
47
+ });
48
+ },
49
+ reset: async () => documents.clear(),
50
+ snapshot: async () => Object.fromEntries([...documents.entries()].sort()),
51
+ };
52
+ }
53
+
54
+ export function createQueueProjectionAdapter({ topic = 'ssss.mutations' } = {}) {
55
+ const messages = [];
56
+ return {
57
+ id: `queue:${topic}`,
58
+ kind: 'queue',
59
+ apply: async (event) => {
60
+ messages.push({ topic, event_id: event.event_id, subject: event.subject, action: event.action });
61
+ },
62
+ reset: async () => { messages.length = 0; },
63
+ snapshot: async () => messages.map((message) => ({ ...message })),
64
+ };
65
+ }
66
+
67
+ export function createViewModelProjectionAdapter({ view = 'detail' } = {}) {
68
+ const models = new Map();
69
+ return {
70
+ id: `view:${view}`,
71
+ kind: 'view_model',
72
+ apply: async (event) => {
73
+ models.set(event.subject, {
74
+ subject: event.subject,
75
+ version: event.after_hash || event.before_hash || null,
76
+ last_action: event.action,
77
+ principal_id: event.principal?.id || null,
78
+ });
79
+ },
80
+ reset: async () => models.clear(),
81
+ snapshot: async () => Object.fromEntries([...models.entries()].sort()),
82
+ };
83
+ }
84
+
85
+ export class ProjectionCoordinator {
86
+ constructor({ eventStore, cursorStore = new Map() } = {}) {
87
+ if (!eventStore) throw new Error('ProjectionCoordinator requires an eventStore.');
88
+ this.eventStore = eventStore;
89
+ this.cursorStore = cursorStore;
90
+ this.projections = new Map();
91
+ }
92
+ register(definition) {
93
+ if (!definition?.id || typeof definition.apply !== 'function') throw new Error('Projection id and apply(event, context) are required.');
94
+ if (this.projections.has(definition.id)) throw new Error(`Duplicate projection '${definition.id}'.`);
95
+ this.projections.set(definition.id, definition);
96
+ }
97
+ async replay(id, options = {}) {
98
+ const projection = this.projections.get(id);
99
+ if (!projection) throw new Error(`Unknown projection '${id}'.`);
100
+ let cursor = options.rebuild ? 0 : (this.cursorStore.get(id) || 0);
101
+ if (options.rebuild && projection.reset) await projection.reset();
102
+ let applied = 0;
103
+ for await (const item of this.eventStore.replay({ cursor, workspaceId: options.workspaceId })) {
104
+ if (!projection.filter || projection.filter(item.event)) {
105
+ await projection.apply(item.event, { replay: true, cursor: item.cursor });
106
+ applied++;
107
+ }
108
+ cursor = item.cursor;
109
+ this.cursorStore.set(id, cursor);
110
+ }
111
+ const state = projection.snapshot ? await projection.snapshot() : null;
112
+ return { id, cursor, applied, output_hash: state === null ? null : projectionHash(state) };
113
+ }
114
+ async dispatch(event) {
115
+ const results = [];
116
+ for (const projection of this.projections.values()) {
117
+ if (projection.filter && !projection.filter(event)) continue;
118
+ try {
119
+ await projection.apply(event, { replay: false });
120
+ results.push({ id: projection.id, success: true });
121
+ } catch (error) {
122
+ results.push({ id: projection.id, success: false, error: error.message });
123
+ }
124
+ }
125
+ return results;
126
+ }
127
+ async detectDrift(id, expectedHash) {
128
+ const projection = this.projections.get(id);
129
+ if (!projection?.snapshot) throw new Error(`Projection '${id}' does not expose snapshot().`);
130
+ const actualHash = projectionHash(await projection.snapshot());
131
+ return { drift: actualHash !== expectedHash, expected_hash: expectedHash, actual_hash: actualHash };
132
+ }
133
+ }