@mh-gg/schema 0.1.1-alpha.20260613T085325975Z
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.
- package/package.json +39 -0
- package/schemas/app-composition.schema.json +684 -0
- package/schemas/example-app.schema.json +64 -0
- package/schemas/example-demo.schema.json +45 -0
- package/schemas/micro-plugin.schema.json +20 -0
- package/src/actions.cjs +31 -0
- package/src/app.d.ts +119 -0
- package/src/builder/app.cjs +157 -0
- package/src/builder/effects.cjs +116 -0
- package/src/builder/guards.cjs +35 -0
- package/src/builder/index.cjs +11 -0
- package/src/builder/matterhornApp/build.cjs +137 -0
- package/src/builder/matterhornApp/bundle.cjs +89 -0
- package/src/builder/matterhornApp/demo.cjs +161 -0
- package/src/builder/matterhornApp/demoAliases.cjs +50 -0
- package/src/builder/matterhornApp/descriptors.cjs +59 -0
- package/src/builder/matterhornApp/exports.cjs +95 -0
- package/src/builder/matterhornApp/frontend.cjs +80 -0
- package/src/builder/matterhornApp/plugins.cjs +95 -0
- package/src/builder/matterhornApp/shared.cjs +105 -0
- package/src/builder/matterhornApp.cjs +7 -0
- package/src/builder/model.cjs +172 -0
- package/src/builder/notifications.cjs +51 -0
- package/src/builder/refs.cjs +33 -0
- package/src/builder/schema.cjs +101 -0
- package/src/builder/streamKey.cjs +27 -0
- package/src/composition.cjs +157 -0
- package/src/configured.cjs +41 -0
- package/src/configured.d.ts +1 -0
- package/src/imports/loader.cjs +86 -0
- package/src/index.cjs +12 -0
- package/src/index.d.ts +168 -0
- package/src/json.cjs +70 -0
- package/src/jsonSchema/validator.cjs +131 -0
- package/src/microPlugin.cjs +112 -0
- package/src/model/collections.cjs +57 -0
- package/src/model/effects.cjs +149 -0
- package/src/model/expressions.cjs +85 -0
- package/src/model/guards.cjs +139 -0
- package/src/model/index.cjs +9 -0
- package/src/model/partitionOperands.cjs +89 -0
- package/src/model/partitionValidator.cjs +95 -0
- package/src/model/payload.cjs +93 -0
- package/src/model/plugin.cjs +146 -0
- package/src/notifications.cjs +41 -0
- package/src/notifications.d.ts +63 -0
- package/src/registry.cjs +63 -0
- package/src/runtime-exports.d.ts +65 -0
- package/src/streamKey.d.ts +15 -0
- package/src/types/actionTypes.cjs +164 -0
- package/src/types/coreActionPayloadSchemas.cjs +35 -0
- package/src/types/coreFeatures.cjs +29 -0
- package/src/types/entities.cjs +131 -0
- package/src/types/entityAliases.cjs +84 -0
- package/src/types/generator.cjs +145 -0
- package/src/types/index.cjs +5 -0
- package/src/types/interfaceBlock.cjs +41 -0
- package/src/types/pluginEntities.cjs +83 -0
- package/src/types/schema.cjs +148 -0
- package/src/types/standardPluginEntityTypes.cjs +135 -0
- package/test/content-lww-contract.test.cjs +104 -0
- package/test/partitionValidator.test.cjs +92 -0
- package/test/schema-array-effects.test.cjs +116 -0
- package/test/schema-builder-types.test.cjs +383 -0
- package/test/schema-composition.test.cjs +144 -0
- package/test/schema-configured-builders.test.cjs +286 -0
- package/test/schema-imports.test.cjs +66 -0
- package/test/schema-model-interpreter.test.cjs +273 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const { hashCanonical } = require("@mh-gg/base");
|
|
2
|
+
const { assertJsonSerializable, assertPlainObject, freezeJson, nonEmptyString, optionalString } = require("./json.cjs");
|
|
3
|
+
|
|
4
|
+
const MICRO_PLUGIN_SCHEMA_KIND = "matterhorn.micro-plugin.schema";
|
|
5
|
+
const MICRO_PLUGIN_SCHEMA_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
function normalizeCapabilitySet(capabilities = {}) {
|
|
8
|
+
return {
|
|
9
|
+
requires: Object.freeze([...(capabilities.requires || capabilities.required || [])].map(String).sort()),
|
|
10
|
+
provides: Object.freeze([...(capabilities.provides || [])].map(String).sort())
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function operationDescriptorsFromPlugin(plugin) {
|
|
15
|
+
const source = plugin.operationSchemaDescriptor?.operations || {};
|
|
16
|
+
const operations = {};
|
|
17
|
+
for (const [type, descriptor] of Object.entries(source)) {
|
|
18
|
+
if (descriptor && typeof descriptor === "object" && typeof descriptor.parse === "function") {
|
|
19
|
+
operations[type] = { type: "object", description: `${type} payload` };
|
|
20
|
+
} else {
|
|
21
|
+
operations[type] = descriptor && typeof descriptor === "object" ? descriptor : { type: "object" };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return operations;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function viewDescriptorsFromPlugin(plugin) {
|
|
28
|
+
const views = [];
|
|
29
|
+
if (plugin.schemas?.publicView) views.push({ name: "publicView", kind: "public-view", plugin: plugin.id });
|
|
30
|
+
for (const queryName of Object.keys(plugin.schemas?.queries || plugin.queries || {}).sort()) {
|
|
31
|
+
views.push({ name: queryName, kind: "query", plugin: plugin.id, query: queryName });
|
|
32
|
+
}
|
|
33
|
+
return views;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function actionNameFromOperation(type) {
|
|
37
|
+
return String(type).replace(/(^|\.)([a-z])/g, (_match, _prefix, letter) => letter.toUpperCase()).replace(/^[A-Z]/, (letter) => letter.toLowerCase());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function actionDescriptorsFromOperations(plugin, operationSchemas) {
|
|
41
|
+
return Object.keys(operationSchemas).sort().map((type) => ({
|
|
42
|
+
name: actionNameFromOperation(type),
|
|
43
|
+
plugin: plugin.id,
|
|
44
|
+
type,
|
|
45
|
+
payloadSchema: operationSchemas[type]
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createMicroPluginSchema(input = {}) {
|
|
50
|
+
const value = assertPlainObject(input, "micro plugin schema");
|
|
51
|
+
const plugin = value.plugin;
|
|
52
|
+
const id = nonEmptyString(value.id || plugin?.id, "micro plugin id");
|
|
53
|
+
const version = nonEmptyString(value.version || plugin?.version, "micro plugin version");
|
|
54
|
+
const key = nonEmptyString(value.key || id, "micro plugin key");
|
|
55
|
+
const source = value.source || (value.packageName && value.exportName ? {
|
|
56
|
+
packageName: value.packageName,
|
|
57
|
+
exportName: value.exportName,
|
|
58
|
+
ref: `workspace:${value.packageName}#${value.exportName}`
|
|
59
|
+
} : undefined);
|
|
60
|
+
const stateSchema = value.stateSchema || plugin?.stateSchemaDescriptor || plugin?.schemas?.state?.descriptor || plugin?.schemas?.state || { type: "object" };
|
|
61
|
+
const operationSchemas = value.operationSchemas || (plugin ? operationDescriptorsFromPlugin(plugin) : {});
|
|
62
|
+
const schema = {
|
|
63
|
+
kind: MICRO_PLUGIN_SCHEMA_KIND,
|
|
64
|
+
schemaVersion: MICRO_PLUGIN_SCHEMA_VERSION,
|
|
65
|
+
key,
|
|
66
|
+
id,
|
|
67
|
+
version,
|
|
68
|
+
name: optionalString(value.name || plugin?.meta?.name, "micro plugin name") || id,
|
|
69
|
+
source,
|
|
70
|
+
capabilities: normalizeCapabilitySet(value.capabilities || plugin?.capabilities),
|
|
71
|
+
schemas: {
|
|
72
|
+
state: stateSchema,
|
|
73
|
+
operations: operationSchemas
|
|
74
|
+
},
|
|
75
|
+
views: value.views || (plugin ? viewDescriptorsFromPlugin(plugin) : []),
|
|
76
|
+
actions: value.actions || (plugin ? actionDescriptorsFromOperations(plugin, operationSchemas) : [])
|
|
77
|
+
};
|
|
78
|
+
assertJsonSerializable(schema, "micro plugin schema");
|
|
79
|
+
return freezeJson({ ...schema, schemaHash: hashCanonical(schema) });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateMicroPluginSchema(schema) {
|
|
83
|
+
const value = assertPlainObject(schema, "micro plugin schema");
|
|
84
|
+
if (value.kind !== MICRO_PLUGIN_SCHEMA_KIND) throw new Error("Invalid micro plugin schema kind");
|
|
85
|
+
if (value.schemaVersion !== MICRO_PLUGIN_SCHEMA_VERSION) throw new Error("Invalid micro plugin schema version");
|
|
86
|
+
nonEmptyString(value.key, "micro plugin key");
|
|
87
|
+
nonEmptyString(value.id, "micro plugin id");
|
|
88
|
+
nonEmptyString(value.version, "micro plugin version");
|
|
89
|
+
assertPlainObject(value.schemas, "micro plugin schemas");
|
|
90
|
+
assertPlainObject(value.schemas.state, "micro plugin state schema");
|
|
91
|
+
assertPlainObject(value.schemas.operations, "micro plugin operation schemas");
|
|
92
|
+
assertJsonSerializable(value, "micro plugin schema");
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function microPluginSchemaHash(schema) {
|
|
97
|
+
const copy = { ...validateMicroPluginSchema(schema) };
|
|
98
|
+
delete copy.schemaHash;
|
|
99
|
+
return hashCanonical(copy);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
MICRO_PLUGIN_SCHEMA_KIND,
|
|
104
|
+
MICRO_PLUGIN_SCHEMA_VERSION,
|
|
105
|
+
actionDescriptorsFromOperations,
|
|
106
|
+
actionNameFromOperation,
|
|
107
|
+
createMicroPluginSchema,
|
|
108
|
+
microPluginSchemaHash,
|
|
109
|
+
operationDescriptorsFromPlugin,
|
|
110
|
+
validateMicroPluginSchema,
|
|
111
|
+
viewDescriptorsFromPlugin
|
|
112
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const { assignedOperationLedgerId, evaluate, getPath, setPath } = require('./expressions.cjs');
|
|
2
|
+
|
|
3
|
+
function asArrayCollection(state, path) {
|
|
4
|
+
const current = getPath(state, path);
|
|
5
|
+
return Array.isArray(current) ? current : [];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function asMapCollection(state, path) {
|
|
9
|
+
const current = getPath(state, path);
|
|
10
|
+
return current && typeof current === 'object' && !Array.isArray(current) ? current : {};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function recordIdFrom(effect, ctx) {
|
|
14
|
+
if (effect.id) return evaluate(effect.id, ctx);
|
|
15
|
+
if (effect.idField && ctx.payload[effect.idField]) return ctx.payload[effect.idField];
|
|
16
|
+
if (effect.idPath) return getPath(ctx.payload, effect.idPath);
|
|
17
|
+
if (effect.idPrefix) return evaluate(`$id:${effect.idPrefix}`, ctx);
|
|
18
|
+
return assignedOperationLedgerId(ctx.operation) || evaluate('$operation.id', ctx);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function findRecordInArray(items, id) {
|
|
22
|
+
return items.find((item) => item && item.id === id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureRecord(record, id, label = 'Record') {
|
|
26
|
+
if (!record) throw new Error(`${label} ${id} not found`);
|
|
27
|
+
return record;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function recordFromCollection(state, effect, ctx) {
|
|
31
|
+
const id = recordIdFrom(effect, ctx);
|
|
32
|
+
if (effect.storage === 'map') {
|
|
33
|
+
const map = asMapCollection(state, effect.collection);
|
|
34
|
+
return { id, record: ensureRecord(map[id], id, effect.recordLabel || 'Record') };
|
|
35
|
+
}
|
|
36
|
+
const items = asArrayCollection(state, effect.collection);
|
|
37
|
+
return { id, record: ensureRecord(findRecordInArray(items, id), id, effect.recordLabel || 'Record') };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeRecordToCollection(state, effect, id, record) {
|
|
41
|
+
if (effect.storage === 'map') {
|
|
42
|
+
const map = asMapCollection(state, effect.collection);
|
|
43
|
+
return setPath(state, effect.collection, { ...map, [id]: record });
|
|
44
|
+
}
|
|
45
|
+
const items = asArrayCollection(state, effect.collection);
|
|
46
|
+
return setPath(state, effect.collection, items.map((item) => item && item.id === id ? record : item));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
asArrayCollection,
|
|
51
|
+
asMapCollection,
|
|
52
|
+
ensureRecord,
|
|
53
|
+
findRecordInArray,
|
|
54
|
+
recordFromCollection,
|
|
55
|
+
recordIdFrom,
|
|
56
|
+
writeRecordToCollection
|
|
57
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const { cloneJson } = require('../json.cjs');
|
|
2
|
+
const { evaluate, getPath, prefixedOperationId, setPath } = require('./expressions.cjs');
|
|
3
|
+
const { asArrayCollection, asMapCollection, ensureRecord, findRecordInArray, recordFromCollection, recordIdFrom, writeRecordToCollection } = require('./collections.cjs');
|
|
4
|
+
|
|
5
|
+
const SUPPORTED_SCHEMA_EFFECT_KINDS = Object.freeze([
|
|
6
|
+
'noop',
|
|
7
|
+
'createRecord',
|
|
8
|
+
'upsertActorRecord',
|
|
9
|
+
'updateRecord',
|
|
10
|
+
'markRecord',
|
|
11
|
+
'mergePath',
|
|
12
|
+
'appendToArray',
|
|
13
|
+
'toggleReaction',
|
|
14
|
+
'insertIdIntoRecordArray',
|
|
15
|
+
'removeIdFromRecordArray'
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function withActivity(state, ctx, message) {
|
|
19
|
+
if (!message) return state;
|
|
20
|
+
const activity = Array.isArray(state.activity) ? state.activity.slice() : [];
|
|
21
|
+
activity.push({ id: prefixedOperationId('activity', ctx.operation), operationId: ctx.operation.id, ledgerId: ctx.operation.ledgerId, actorId: ctx.actor.memberId, actorName: ctx.actor.displayName || ctx.actor.memberId, message, createdAt: ctx.operation.createdAt });
|
|
22
|
+
return { ...state, activity };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function applyCreateRecord(state, effect, ctx) {
|
|
26
|
+
const id = recordIdFrom(effect, ctx);
|
|
27
|
+
const record = { id, ...evaluate(effect.fields || {}, { ...ctx, recordId: id }) };
|
|
28
|
+
if (effect.storage === 'array') {
|
|
29
|
+
const items = asArrayCollection(state, effect.collection);
|
|
30
|
+
return setPath(state, effect.collection, [...items, record]);
|
|
31
|
+
}
|
|
32
|
+
const map = asMapCollection(state, effect.collection);
|
|
33
|
+
return setPath(state, effect.collection, { ...map, [id]: record });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function applyUpsertActorRecord(state, effect, ctx) {
|
|
37
|
+
const id = ctx.actor.memberId;
|
|
38
|
+
const map = asMapCollection(state, effect.collection);
|
|
39
|
+
const existing = map[id] || { id, joinedAt: ctx.operation.createdAt };
|
|
40
|
+
const record = { ...existing, ...evaluate(effect.fields || {}, ctx), id };
|
|
41
|
+
return setPath(state, effect.collection, { ...map, [id]: record });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function applyUpdateRecord(state, effect, ctx) {
|
|
45
|
+
const id = recordIdFrom(effect, ctx);
|
|
46
|
+
if (effect.storage === 'array') {
|
|
47
|
+
const items = asArrayCollection(state, effect.collection);
|
|
48
|
+
const record = ensureRecord(findRecordInArray(items, id), id, effect.recordLabel || 'Record');
|
|
49
|
+
const updated = { ...record, ...evaluate(effect.fields || {}, { ...ctx, record }) };
|
|
50
|
+
return setPath(state, effect.collection, items.map((item) => item.id === id ? updated : item));
|
|
51
|
+
}
|
|
52
|
+
const map = asMapCollection(state, effect.collection);
|
|
53
|
+
const record = ensureRecord(map[id], id, effect.recordLabel || 'Record');
|
|
54
|
+
const updated = { ...record, ...evaluate(effect.fields || {}, { ...ctx, record }) };
|
|
55
|
+
return setPath(state, effect.collection, { ...map, [id]: updated });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function applyMergePath(state, effect, ctx) {
|
|
59
|
+
const current = getPath(state, effect.path) || {};
|
|
60
|
+
const merged = { ...current, ...evaluate(effect.fields || '$payload', ctx) };
|
|
61
|
+
if (effect.deleteNullFields === true) {
|
|
62
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
63
|
+
if (value === null) delete merged[key];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return setPath(state, effect.path, merged);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function applyAppendToArray(state, effect, ctx) {
|
|
70
|
+
const items = asArrayCollection(state, effect.path);
|
|
71
|
+
return setPath(state, effect.path, [...items, evaluate(effect.item || '$payload', ctx)]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function applyToggleReaction(state, effect, ctx) {
|
|
75
|
+
const id = recordIdFrom(effect, ctx);
|
|
76
|
+
const map = asMapCollection(state, effect.collection);
|
|
77
|
+
const record = ensureRecord(map[id], id, effect.recordLabel || 'Record');
|
|
78
|
+
const emoji = ctx.payload[effect.emojiField || 'emoji'];
|
|
79
|
+
const reactions = { ...(record.reactions || {}) };
|
|
80
|
+
const current = new Set(reactions[emoji] || []);
|
|
81
|
+
if (current.has(ctx.actor.memberId)) current.delete(ctx.actor.memberId);
|
|
82
|
+
else current.add(ctx.actor.memberId);
|
|
83
|
+
reactions[emoji] = [...current];
|
|
84
|
+
return setPath(state, effect.collection, { ...map, [id]: { ...record, reactions } });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function recordAndIdForArrayEffect(state, effect, ctx) {
|
|
88
|
+
if (typeof effect.collection !== 'string' || effect.collection.length === 0) throw new Error('Array record effect collection is required');
|
|
89
|
+
if (typeof effect.arrayField !== 'string' || effect.arrayField.length === 0) throw new Error('Array record effect arrayField is required');
|
|
90
|
+
if (!Object.prototype.hasOwnProperty.call(effect, 'value')) throw new Error('Array record effect value is required');
|
|
91
|
+
return recordFromCollection(state, effect, ctx);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function finitePosition(position) {
|
|
95
|
+
return typeof position === 'number' && Number.isFinite(position);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function applyInsertIdIntoRecordArray(state, effect, ctx) {
|
|
99
|
+
const { id, record } = recordAndIdForArrayEffect(state, effect, ctx);
|
|
100
|
+
const arrayField = effect.arrayField;
|
|
101
|
+
const nextId = evaluate(effect.value, ctx);
|
|
102
|
+
const rawPosition = effect.position === undefined ? undefined : evaluate(effect.position, ctx);
|
|
103
|
+
const positioned = finitePosition(rawPosition);
|
|
104
|
+
const values = Array.isArray(record[arrayField]) ? record[arrayField].slice() : [];
|
|
105
|
+
|
|
106
|
+
if (positioned) {
|
|
107
|
+
const withoutExisting = values.filter((value) => value !== nextId);
|
|
108
|
+
const index = Math.max(0, Math.min(Math.trunc(rawPosition), withoutExisting.length));
|
|
109
|
+
withoutExisting.splice(index, 0, nextId);
|
|
110
|
+
return writeRecordToCollection(state, effect, id, { ...record, [arrayField]: withoutExisting });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (values.includes(nextId)) return state;
|
|
114
|
+
values.push(nextId);
|
|
115
|
+
return writeRecordToCollection(state, effect, id, { ...record, [arrayField]: values });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function applyRemoveIdFromRecordArray(state, effect, ctx) {
|
|
119
|
+
const { id, record } = recordAndIdForArrayEffect(state, effect, ctx);
|
|
120
|
+
const arrayField = effect.arrayField;
|
|
121
|
+
const removeId = evaluate(effect.value, ctx);
|
|
122
|
+
const values = Array.isArray(record[arrayField]) ? record[arrayField].filter((value) => value !== removeId) : [];
|
|
123
|
+
return writeRecordToCollection(state, effect, id, { ...record, [arrayField]: values });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function applyEffect(state, effect, ctx) {
|
|
127
|
+
if (!effect || effect.kind === 'noop') return state;
|
|
128
|
+
let next;
|
|
129
|
+
if (effect.kind === 'createRecord') next = applyCreateRecord(state, effect, ctx);
|
|
130
|
+
else if (effect.kind === 'upsertActorRecord') next = applyUpsertActorRecord(state, effect, ctx);
|
|
131
|
+
else if (effect.kind === 'updateRecord' || effect.kind === 'markRecord') next = applyUpdateRecord(state, effect, ctx);
|
|
132
|
+
else if (effect.kind === 'mergePath') next = applyMergePath(state, effect, ctx);
|
|
133
|
+
else if (effect.kind === 'appendToArray') next = applyAppendToArray(state, effect, ctx);
|
|
134
|
+
else if (effect.kind === 'toggleReaction') next = applyToggleReaction(state, effect, ctx);
|
|
135
|
+
else if (effect.kind === 'insertIdIntoRecordArray') next = applyInsertIdIntoRecordArray(state, effect, ctx);
|
|
136
|
+
else if (effect.kind === 'removeIdFromRecordArray') next = applyRemoveIdFromRecordArray(state, effect, ctx);
|
|
137
|
+
else throw new Error(`Unsupported schema effect ${effect.kind}`);
|
|
138
|
+
return withActivity(next, ctx, effect.activity);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function applyEffects(state, effects = [], ctx) {
|
|
142
|
+
return effects.reduce((current, effect) => applyEffect(current, effect, ctx), cloneJson(state));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
SUPPORTED_SCHEMA_EFFECT_KINDS,
|
|
147
|
+
applyEffect,
|
|
148
|
+
applyEffects
|
|
149
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const { cloneJson } = require('../json.cjs');
|
|
2
|
+
const { isSnowflakeId, prefixedSnowflakeId, snowflakeIdForOperation } = require('@mh-gg/protocol');
|
|
3
|
+
|
|
4
|
+
function sanitizeId(value) {
|
|
5
|
+
return String(value || '').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function assignedOperationLedgerId(operation = {}) {
|
|
9
|
+
const id = operation.ledgerId || operation.snowflakeId;
|
|
10
|
+
return id && isSnowflakeId(id) ? String(id) : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function operationLedgerId(operation = {}, options = {}) {
|
|
14
|
+
const assigned = assignedOperationLedgerId(operation);
|
|
15
|
+
if (assigned) return assigned;
|
|
16
|
+
return options.synthetic === false ? undefined : snowflakeIdForOperation(operation);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function prefixedOperationId(prefix, operation = {}) {
|
|
20
|
+
const ledgerId = assignedOperationLedgerId(operation);
|
|
21
|
+
if (ledgerId) return prefixedSnowflakeId(prefix, ledgerId);
|
|
22
|
+
return `${prefix}_${sanitizeId(operation.clientOperationId || operation.id)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getPath(root, path) {
|
|
26
|
+
if (!path) return root;
|
|
27
|
+
return String(path).split('.').reduce((value, part) => (value == null ? undefined : value[part]), root);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function setPath(root, path, nextValue) {
|
|
31
|
+
const parts = String(path).split('.').filter(Boolean);
|
|
32
|
+
if (parts.length === 0) return nextValue;
|
|
33
|
+
const copy = Array.isArray(root) ? root.slice() : { ...(root || {}) };
|
|
34
|
+
let cursor = copy;
|
|
35
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
36
|
+
const part = parts[index];
|
|
37
|
+
const current = cursor[part];
|
|
38
|
+
cursor[part] = Array.isArray(current) ? current.slice() : { ...(current || {}) };
|
|
39
|
+
cursor = cursor[part];
|
|
40
|
+
}
|
|
41
|
+
cursor[parts.at(-1)] = nextValue;
|
|
42
|
+
return copy;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function evaluateStringExpression(expr, ctx) {
|
|
46
|
+
if (expr.startsWith('$payload.')) return getPath(ctx.payload, expr.slice('$payload.'.length));
|
|
47
|
+
if (expr === '$payload') return ctx.payload;
|
|
48
|
+
if (expr.startsWith('$actor.')) return getPath(ctx.actor, expr.slice('$actor.'.length));
|
|
49
|
+
if (expr === '$actor') return ctx.actor;
|
|
50
|
+
if (expr.startsWith('$app.')) return getPath(ctx.app, expr.slice('$app.'.length));
|
|
51
|
+
if (expr === '$app') return ctx.app;
|
|
52
|
+
if (expr.startsWith('$room.')) return getPath(ctx.room, expr.slice('$room.'.length));
|
|
53
|
+
if (expr === '$room') return ctx.room;
|
|
54
|
+
if (expr === '$createdAt') return ctx.operation.createdAt;
|
|
55
|
+
if (expr === '$operation.id') return ctx.operation.id;
|
|
56
|
+
if (expr === '$operation.ledgerId' || expr === '$operation.snowflakeId') return operationLedgerId(ctx.operation);
|
|
57
|
+
if (expr === '$operation.type') return ctx.operation.type;
|
|
58
|
+
if (expr.startsWith('$id:')) return prefixedOperationId(expr.slice('$id:'.length), ctx.operation);
|
|
59
|
+
if (expr.startsWith('$const:')) return expr.slice('$const:'.length);
|
|
60
|
+
return expr;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function evaluate(value, ctx) {
|
|
64
|
+
if (typeof value === 'string') return value.startsWith('$') ? evaluateStringExpression(value, ctx) : value;
|
|
65
|
+
if (Array.isArray(value)) return value.map((item) => evaluate(item, ctx));
|
|
66
|
+
if (!value || typeof value !== 'object') return value;
|
|
67
|
+
if (Object.prototype.hasOwnProperty.call(value, '$expr')) {
|
|
68
|
+
const evaluated = evaluateStringExpression(value.$expr, ctx);
|
|
69
|
+
if (evaluated === undefined && Object.prototype.hasOwnProperty.call(value, 'fallback')) return cloneJson(value.fallback);
|
|
70
|
+
return evaluated;
|
|
71
|
+
}
|
|
72
|
+
if (Object.prototype.hasOwnProperty.call(value, '$literal')) return cloneJson(value.$literal);
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
75
|
+
const evaluated = evaluate(entry, ctx);
|
|
76
|
+
if (evaluated !== undefined) out[key] = evaluated;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function collectionValue(state, path) {
|
|
82
|
+
return getPath(state, path) || (path.endsWith('s') ? [] : {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { assignedOperationLedgerId, collectionValue, evaluate, getPath, operationLedgerId, prefixedOperationId, sanitizeId, setPath };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const { asArrayCollection, asMapCollection, findRecordInArray, recordIdFrom } = require('./collections.cjs');
|
|
2
|
+
const { evaluate } = require('./expressions.cjs');
|
|
3
|
+
|
|
4
|
+
const ROLE_RANKS = Object.freeze({
|
|
5
|
+
guest: 0,
|
|
6
|
+
member: 1,
|
|
7
|
+
user: 1,
|
|
8
|
+
moderator: 2,
|
|
9
|
+
admin: 3,
|
|
10
|
+
owner: 4
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function roleRank(role) {
|
|
14
|
+
return ROLE_RANKS[role === 'user' ? 'member' : role] ?? ROLE_RANKS.guest;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function roleMeetsAny(actor, roles = []) {
|
|
18
|
+
return roles.some((role) => roleRank(actor?.role) >= roleRank(role));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function recordForGuard(state, guard, ctx) {
|
|
22
|
+
const id = recordIdFrom(guard, ctx);
|
|
23
|
+
if (guard.storage === 'map') return asMapCollection(state, guard.collection)[id];
|
|
24
|
+
return findRecordInArray(asArrayCollection(state, guard.collection), id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertRecordOwnerOrRole(state, guard, ctx) {
|
|
28
|
+
if (roleMeetsAny(ctx.actor, guard.roles || [])) return;
|
|
29
|
+
const record = recordForGuard(state, guard, ctx);
|
|
30
|
+
if (!record || record[guard.ownerField || 'authorId'] !== ctx.actor.memberId) {
|
|
31
|
+
throw new Error(guard.message || 'Only the owner or an allowed role can perform this operation');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertRecordFlagClear(state, guard, ctx) {
|
|
36
|
+
const record = recordForGuard(state, guard, ctx);
|
|
37
|
+
if (record?.[guard.flag]) throw new Error(guard.message || `${guard.flag} blocks this operation`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pathSegments(path, ctx) {
|
|
41
|
+
const parts = [];
|
|
42
|
+
const text = String(path || '').replace(/\[([^\]]+)\]/g, (_m, inner) => {
|
|
43
|
+
let key = inner.trim();
|
|
44
|
+
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) key = key.slice(1, -1);
|
|
45
|
+
else if (key.startsWith('$')) key = evaluate(key, ctx);
|
|
46
|
+
return `.${String(key).replace(/\./g, '\\.')}`;
|
|
47
|
+
});
|
|
48
|
+
for (const raw of text.split('.')) {
|
|
49
|
+
if (!raw) continue;
|
|
50
|
+
parts.push(raw.replace(/\\\./g, '.'));
|
|
51
|
+
}
|
|
52
|
+
return parts;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getPathWithRuntimeKeys(root, path, ctx) {
|
|
56
|
+
const parts = pathSegments(path, ctx);
|
|
57
|
+
return parts.reduce((value, part) => (value == null ? undefined : value[part]), root);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function statePathValue(state, path, ctx) {
|
|
61
|
+
if (typeof path !== 'string') return path;
|
|
62
|
+
if (path.startsWith('$state.')) return getPathWithRuntimeKeys(state, path.slice('$state.'.length), ctx);
|
|
63
|
+
if (path === '$state') return state;
|
|
64
|
+
if (path.startsWith('state.')) return getPathWithRuntimeKeys(state, path.slice('state.'.length), ctx);
|
|
65
|
+
if (path.startsWith('$')) return evaluate(path, ctx);
|
|
66
|
+
return getPathWithRuntimeKeys(state, path, ctx);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function operandValue(state, operand, ctx) {
|
|
70
|
+
if (!operand || typeof operand !== 'object' || Array.isArray(operand)) {
|
|
71
|
+
if (typeof operand === 'string' && operand.startsWith('$')) return evaluate(operand, ctx);
|
|
72
|
+
return operand;
|
|
73
|
+
}
|
|
74
|
+
if (operand.kind === 'value.literal') return operand.value;
|
|
75
|
+
if (operand.kind === 'value.expr') return evaluate(operand.expr, ctx);
|
|
76
|
+
if (operand.kind === 'value.path') return statePathValue(state, operand.path, ctx);
|
|
77
|
+
if (operand.kind === 'value.length') {
|
|
78
|
+
const value = statePathValue(state, operand.path, ctx);
|
|
79
|
+
if (Array.isArray(value) || typeof value === 'string') return value.length;
|
|
80
|
+
if (value && typeof value === 'object') return Object.keys(value).length;
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
if (operand.kind === 'length') {
|
|
84
|
+
const value = statePathValue(state, operand.path, ctx);
|
|
85
|
+
if (Array.isArray(value) || typeof value === 'string') return value.length;
|
|
86
|
+
if (value && typeof value === 'object') return Object.keys(value).length;
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (Object.prototype.hasOwnProperty.call(operand, 'value')) return operand.value;
|
|
90
|
+
if (operand.path) return statePathValue(state, operand.path, ctx);
|
|
91
|
+
return operand;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function compareValues(kind, left, right) {
|
|
95
|
+
if (kind === 'eq') return left === right;
|
|
96
|
+
if (kind === 'ne') return left !== right;
|
|
97
|
+
if (kind === 'lt') return Number(left) < Number(right);
|
|
98
|
+
if (kind === 'lte') return Number(left) <= Number(right);
|
|
99
|
+
if (kind === 'gt') return Number(left) > Number(right);
|
|
100
|
+
if (kind === 'gte') return Number(left) >= Number(right);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function guardPasses(state, guard, ctx) {
|
|
105
|
+
if (!guard || guard.kind === 'noop') return true;
|
|
106
|
+
if (guard.kind === 'recordOwnerOrRole') {
|
|
107
|
+
assertRecordOwnerOrRole(state, guard, ctx);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
if (guard.kind === 'recordFlagClear') {
|
|
111
|
+
assertRecordFlagClear(state, guard, ctx);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
if (['eq', 'ne', 'lt', 'lte', 'gt', 'gte'].includes(guard.kind)) return compareValues(guard.kind, operandValue(state, guard.left, ctx), operandValue(state, guard.right, ctx));
|
|
115
|
+
if (guard.kind === 'exists') return statePathValue(state, guard.path, ctx) !== undefined;
|
|
116
|
+
if (guard.kind === 'and') return (guard.guards || []).every((item) => guardPasses(state, item, ctx));
|
|
117
|
+
if (guard.kind === 'or') return (guard.guards || []).some((item) => guardPasses(state, item, ctx));
|
|
118
|
+
if (guard.kind === 'not') return !guardPasses(state, guard.guard, ctx);
|
|
119
|
+
throw new Error(`Unsupported schema guard ${guard.kind}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function applyGuard(state, guard, ctx) {
|
|
123
|
+
if (!guard || guard.kind === 'noop') return;
|
|
124
|
+
const ok = guardPasses(state, guard, ctx);
|
|
125
|
+
if (!ok) throw new Error(guard.message || 'Schema guard rejected this operation');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function applyGuards(state, guards = [], ctx) {
|
|
129
|
+
for (const guard of guards) applyGuard(state, guard, ctx);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
applyGuard,
|
|
134
|
+
applyGuards,
|
|
135
|
+
guardPasses,
|
|
136
|
+
roleMeetsAny,
|
|
137
|
+
roleRank,
|
|
138
|
+
statePathValue
|
|
139
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const { SUPPORTED_SCHEMA_EFFECT_KINDS } = require('./effects.cjs');
|
|
2
|
+
|
|
3
|
+
const READ_CLASSES_CHUNKABLE = Object.freeze(new Set(['window', 'head']));
|
|
4
|
+
|
|
5
|
+
function topLevelPath(path) {
|
|
6
|
+
if (typeof path !== 'string' || path.length === 0) return undefined;
|
|
7
|
+
let normalized = path;
|
|
8
|
+
if (normalized.startsWith('$state.')) normalized = normalized.slice(7);
|
|
9
|
+
else if (normalized === '$state') return undefined;
|
|
10
|
+
else if (normalized.startsWith('state.')) normalized = normalized.slice(6);
|
|
11
|
+
else if (normalized.startsWith('$payload.')) return undefined;
|
|
12
|
+
else if (normalized.startsWith('$')) return undefined;
|
|
13
|
+
const first = normalized.split('.')[0];
|
|
14
|
+
return first || undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function partitionsFromOperand(operand) {
|
|
18
|
+
const parts = new Set();
|
|
19
|
+
if (!operand || typeof operand !== 'object') return parts;
|
|
20
|
+
if (operand.path) {
|
|
21
|
+
const p = topLevelPath(operand.path);
|
|
22
|
+
if (p) parts.add(p);
|
|
23
|
+
}
|
|
24
|
+
if (operand.kind === 'value.path' && operand.path) {
|
|
25
|
+
const p = topLevelPath(operand.path);
|
|
26
|
+
if (p) parts.add(p);
|
|
27
|
+
}
|
|
28
|
+
if (operand.kind === 'value.length' && operand.path) {
|
|
29
|
+
const p = topLevelPath(operand.path);
|
|
30
|
+
if (p) parts.add(p);
|
|
31
|
+
}
|
|
32
|
+
if (operand.kind === 'length' && operand.path) {
|
|
33
|
+
const p = topLevelPath(operand.path);
|
|
34
|
+
if (p) parts.add(p);
|
|
35
|
+
}
|
|
36
|
+
return parts;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function partitionsFromGuard(guard) {
|
|
40
|
+
const parts = new Set();
|
|
41
|
+
if (!guard || guard.kind === 'noop') return parts;
|
|
42
|
+
if (guard.kind === 'recordOwnerOrRole' || guard.kind === 'recordFlagClear') {
|
|
43
|
+
if (guard.collection) parts.add(guard.collection);
|
|
44
|
+
return parts;
|
|
45
|
+
}
|
|
46
|
+
if (['eq', 'ne', 'lt', 'lte', 'gt', 'gte'].includes(guard.kind)) {
|
|
47
|
+
for (const p of partitionsFromOperand(guard.left)) parts.add(p);
|
|
48
|
+
for (const p of partitionsFromOperand(guard.right)) parts.add(p);
|
|
49
|
+
return parts;
|
|
50
|
+
}
|
|
51
|
+
if (guard.kind === 'exists') {
|
|
52
|
+
const p = topLevelPath(guard.path);
|
|
53
|
+
if (p) parts.add(p);
|
|
54
|
+
return parts;
|
|
55
|
+
}
|
|
56
|
+
if (guard.kind === 'and' || guard.kind === 'or') {
|
|
57
|
+
for (const child of guard.guards || []) {
|
|
58
|
+
for (const p of partitionsFromGuard(child)) parts.add(p);
|
|
59
|
+
}
|
|
60
|
+
return parts;
|
|
61
|
+
}
|
|
62
|
+
if (guard.kind === 'not') {
|
|
63
|
+
return partitionsFromGuard(guard.guard);
|
|
64
|
+
}
|
|
65
|
+
return parts;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function partitionsFromEffect(effect) {
|
|
69
|
+
const parts = new Set();
|
|
70
|
+
if (!effect || effect.kind === 'noop') return parts;
|
|
71
|
+
if (effect.partition) parts.add(effect.partition);
|
|
72
|
+
if (effect.kind === 'mergePath' && effect.path) {
|
|
73
|
+
const p = topLevelPath(effect.path);
|
|
74
|
+
if (p) parts.add(p);
|
|
75
|
+
}
|
|
76
|
+
if (effect.kind === 'appendToArray' && effect.path) {
|
|
77
|
+
const p = topLevelPath(effect.path);
|
|
78
|
+
if (p) parts.add(p);
|
|
79
|
+
}
|
|
80
|
+
return parts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
READ_CLASSES_CHUNKABLE,
|
|
85
|
+
partitionsFromEffect,
|
|
86
|
+
partitionsFromGuard,
|
|
87
|
+
partitionsFromOperand,
|
|
88
|
+
topLevelPath
|
|
89
|
+
};
|