@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,95 @@
|
|
|
1
|
+
const { READ_CLASSES_CHUNKABLE, partitionsFromEffect, partitionsFromGuard } = require('./partitionOperands.cjs');
|
|
2
|
+
|
|
3
|
+
function operationPartitions(operation) {
|
|
4
|
+
const parts = new Set();
|
|
5
|
+
for (const effect of operation.effects || []) {
|
|
6
|
+
for (const p of partitionsFromEffect(effect)) parts.add(p);
|
|
7
|
+
}
|
|
8
|
+
return parts;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function validateOperationShape(operationName, operation, shapes = {}) {
|
|
12
|
+
const issues = [];
|
|
13
|
+
const effectPartitions = operationPartitions(operation);
|
|
14
|
+
const affectedChunkableShapes = [];
|
|
15
|
+
for (const part of effectPartitions) {
|
|
16
|
+
const shape = shapes[part];
|
|
17
|
+
if (shape && READ_CLASSES_CHUNKABLE.has(shape.readClass)) {
|
|
18
|
+
affectedChunkableShapes.push(part);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (affectedChunkableShapes.length === 0) return issues;
|
|
22
|
+
|
|
23
|
+
for (const guard of operation.guards || []) {
|
|
24
|
+
const guardParts = partitionsFromGuard(guard);
|
|
25
|
+
for (const gp of guardParts) {
|
|
26
|
+
if (!effectPartitions.has(gp)) {
|
|
27
|
+
issues.push({
|
|
28
|
+
operation: operationName,
|
|
29
|
+
type: 'cross-partition-guard',
|
|
30
|
+
guardKind: guard.kind,
|
|
31
|
+
partition: gp,
|
|
32
|
+
message: `Guard reads from partition "${gp}" but operation only writes to [${[...effectPartitions].join(', ')}]. ` +
|
|
33
|
+
`Chunkable shape "${affectedChunkableShapes.join(', ')}" cannot depend on another partition.`
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const effect of operation.effects || []) {
|
|
40
|
+
const fxParts = partitionsFromEffect(effect);
|
|
41
|
+
for (const fp of fxParts) {
|
|
42
|
+
const shape = shapes[fp];
|
|
43
|
+
if (shape && READ_CLASSES_CHUNKABLE.has(shape.readClass)) {
|
|
44
|
+
for (const other of fxParts) {
|
|
45
|
+
if (other !== fp && !effectPartitions.has(other)) {
|
|
46
|
+
issues.push({
|
|
47
|
+
operation: operationName,
|
|
48
|
+
type: 'cross-partition-effect',
|
|
49
|
+
effectKind: effect.kind,
|
|
50
|
+
partition: other,
|
|
51
|
+
message: `Effect touches chunkable shape "${fp}" and also reads from "${other}".`
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const effect of operation.effects || []) {
|
|
60
|
+
const part = effect.partition;
|
|
61
|
+
if (!part) continue;
|
|
62
|
+
const shape = shapes[part];
|
|
63
|
+
if (shape && shape.readClass === 'window') {
|
|
64
|
+
if (effect.kind === 'updateRecord' || effect.kind === 'markRecord' || effect.kind === 'removeIdFromRecordArray') {
|
|
65
|
+
issues.push({
|
|
66
|
+
operation: operationName,
|
|
67
|
+
type: 'non-crdt-window-effect',
|
|
68
|
+
effectKind: effect.kind,
|
|
69
|
+
partition: part,
|
|
70
|
+
message: `Window shape "${part}" does not support "${effect.kind}" because it is not append-only.`
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return issues;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateModelShapes(modelJson) {
|
|
80
|
+
const issues = [];
|
|
81
|
+
const shapes = modelJson?.shapes || {};
|
|
82
|
+
const operations = modelJson?.operations || {};
|
|
83
|
+
for (const [name, op] of Object.entries(operations)) {
|
|
84
|
+
issues.push(...validateOperationShape(name, op, shapes));
|
|
85
|
+
}
|
|
86
|
+
return issues;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
operationPartitions,
|
|
91
|
+
partitionsFromEffect,
|
|
92
|
+
partitionsFromGuard,
|
|
93
|
+
validateModelShapes,
|
|
94
|
+
validateOperationShape
|
|
95
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const { cloneJson } = require('../json.cjs');
|
|
2
|
+
|
|
3
|
+
function schemaEntries(schemaPart) {
|
|
4
|
+
if (!schemaPart) return [];
|
|
5
|
+
if (Array.isArray(schemaPart)) return schemaPart.map((name) => [name, { type: 'string' }]);
|
|
6
|
+
return Object.entries(schemaPart).map(([name, spec]) => [name, typeof spec === 'string' ? { type: spec } : spec || { type: 'any' }]);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function parseValue(value, spec, name) {
|
|
10
|
+
const type = spec.type || 'any';
|
|
11
|
+
if (value === undefined || value === null || (value === '' && spec.nonEmpty !== false)) {
|
|
12
|
+
if (spec.default !== undefined) return cloneJson(spec.default);
|
|
13
|
+
if (spec.nullable) return null;
|
|
14
|
+
throw new Error(`${name} is required`);
|
|
15
|
+
}
|
|
16
|
+
if (type === 'any') return cloneJson(value);
|
|
17
|
+
if (type === 'string') {
|
|
18
|
+
if (typeof value !== 'string') throw new Error(`${name} must be a string`);
|
|
19
|
+
const trimmed = spec.trim === false ? value : value.trim();
|
|
20
|
+
if (!trimmed && spec.nonEmpty !== false) throw new Error(`${name} must be a non-empty string`);
|
|
21
|
+
if (spec.max && trimmed.length > spec.max) {
|
|
22
|
+
if (spec.truncate === true) return trimmed.slice(0, spec.max);
|
|
23
|
+
throw new Error(`${name} is too long`);
|
|
24
|
+
}
|
|
25
|
+
return trimmed;
|
|
26
|
+
}
|
|
27
|
+
if (type === 'number') {
|
|
28
|
+
const parsed = Number(value);
|
|
29
|
+
if (!Number.isFinite(parsed)) throw new Error(`${name} must be a finite number`);
|
|
30
|
+
if (spec.min !== undefined && parsed < spec.min) throw new Error(`${name} must be >= ${spec.min}`);
|
|
31
|
+
if (spec.max !== undefined && parsed > spec.max) throw new Error(`${name} must be <= ${spec.max}`);
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
if (type === 'boolean') return Boolean(value);
|
|
35
|
+
if (type === 'enum') {
|
|
36
|
+
if (!spec.values.includes(value) && spec.fallback !== undefined) return cloneJson(spec.fallback);
|
|
37
|
+
if (!spec.values.includes(value)) throw new Error(`${name} must be one of ${spec.values.join(', ')}`);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
if (type === 'array') {
|
|
41
|
+
if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
|
|
42
|
+
return value.map((item, index) => spec.items ? parseValue(item, spec.items, `${name}[${index}]`) : cloneJson(item));
|
|
43
|
+
}
|
|
44
|
+
if (type === 'object') {
|
|
45
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${name} must be an object`);
|
|
46
|
+
if (spec.required || spec.optional || spec.additional === false) {
|
|
47
|
+
return createPayloadParser({ required: spec.required, optional: spec.optional, additional: spec.additional })(value);
|
|
48
|
+
}
|
|
49
|
+
if (spec.properties && typeof spec.properties === 'object') {
|
|
50
|
+
const shaped = { required: {}, optional: {}, additional: spec.additional };
|
|
51
|
+
for (const [key, child] of Object.entries(spec.properties)) {
|
|
52
|
+
if (child?.optional) shaped.optional[key] = child;
|
|
53
|
+
else shaped.required[key] = child;
|
|
54
|
+
}
|
|
55
|
+
return createPayloadParser(shaped)(value);
|
|
56
|
+
}
|
|
57
|
+
return cloneJson(value);
|
|
58
|
+
}
|
|
59
|
+
if (type === 'record') {
|
|
60
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${name} must be an object`);
|
|
61
|
+
const out = {};
|
|
62
|
+
const child = spec.values || spec.items;
|
|
63
|
+
for (const [key, item] of Object.entries(value)) out[key] = child ? parseValue(item, child, `${name}.${key}`) : cloneJson(item);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
if (type === 'literal') {
|
|
67
|
+
const expected = JSON.stringify(spec.value);
|
|
68
|
+
if (JSON.stringify(value) !== expected) throw new Error(`${name} must equal ${expected}`);
|
|
69
|
+
return cloneJson(value);
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`${name} schema type ${type} is not supported`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createPayloadParser(payloadSchema = {}) {
|
|
75
|
+
const required = schemaEntries(payloadSchema.required);
|
|
76
|
+
const optional = schemaEntries(payloadSchema.optional);
|
|
77
|
+
return function parsePayload(payload = {}) {
|
|
78
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('payload must be an object');
|
|
79
|
+
const out = {};
|
|
80
|
+
for (const [name, spec] of required) out[name] = parseValue(payload[name], spec, name);
|
|
81
|
+
for (const [name, spec] of optional) {
|
|
82
|
+
if (payload[name] !== undefined && payload[name] !== null && (payload[name] !== '' || spec.nonEmpty === false)) out[name] = parseValue(payload[name], spec, name);
|
|
83
|
+
else if (payload[name] === null && (spec.clearable || spec.nullable)) out[name] = null;
|
|
84
|
+
else if (spec.default !== undefined) out[name] = cloneJson(spec.default);
|
|
85
|
+
}
|
|
86
|
+
for (const [name, value] of Object.entries(payload)) {
|
|
87
|
+
if (!(name in out) && payloadSchema.additional !== false) out[name] = cloneJson(value);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { createPayloadParser, parseValue };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const { hashCanonical } = require('@mh-gg/base');
|
|
2
|
+
const { allow, defineHostPlugin, deny } = require('@mh-gg/host-runtime');
|
|
3
|
+
const { assertJsonSerializable, cloneJson, freezeJson } = require('../json.cjs');
|
|
4
|
+
const { validateAppCompositionSchema } = require('../composition.cjs');
|
|
5
|
+
const { createPayloadParser } = require('./payload.cjs');
|
|
6
|
+
const { applyEffects } = require('./effects.cjs');
|
|
7
|
+
const { applyGuards } = require('./guards.cjs');
|
|
8
|
+
const { getPath } = require('./expressions.cjs');
|
|
9
|
+
|
|
10
|
+
const SCHEMA_DEFINED_PLUGIN_SOURCE = 'schema:matterhorn.primary-model';
|
|
11
|
+
|
|
12
|
+
function inferModelFromComposition(composition) {
|
|
13
|
+
const operations = {};
|
|
14
|
+
for (const action of composition.actions.filter((item) => item.plugin === 'primary' || item.plugin === '$primary')) {
|
|
15
|
+
const collection = action.type.split('.')[0] + 's';
|
|
16
|
+
const creates = /(^|\.)(create|add|send|open|log|record|request)$/.test(action.type);
|
|
17
|
+
operations[action.type] = {
|
|
18
|
+
payload: action.payloadSchema || { additional: true },
|
|
19
|
+
authorize: { roles: creates ? ['member'] : ['member'] },
|
|
20
|
+
effects: [creates
|
|
21
|
+
? { kind: 'createRecord', collection, idPrefix: action.type.split('.')[0], fields: { ...action.payloadDefaults, ...{ createdAt: '$createdAt', actorId: '$actor.memberId' }, data: '$payload' } }
|
|
22
|
+
: { kind: 'noop' }]
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return { kind: 'matterhorn.primary-model.schema', schemaVersion: 1, state: { initial: { activity: [] } }, operations, views: {} };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeModel(composition) {
|
|
29
|
+
const model = composition.primaryPlugin.model || inferModelFromComposition(composition);
|
|
30
|
+
if (!model || typeof model !== 'object' || Array.isArray(model)) throw new Error('primaryPlugin.model must be a JSON object');
|
|
31
|
+
assertJsonSerializable(model, 'primary plugin model');
|
|
32
|
+
return freezeJson({
|
|
33
|
+
kind: model.kind || 'matterhorn.primary-model.schema',
|
|
34
|
+
schemaVersion: model.schemaVersion || 1,
|
|
35
|
+
state: model.state || { initial: { activity: [] } },
|
|
36
|
+
operations: model.operations || {},
|
|
37
|
+
views: model.views || {},
|
|
38
|
+
publicView: model.publicView || { kind: 'state' }
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stateDescriptorForModel(composition, model) {
|
|
43
|
+
return { plugin: composition.primaryPlugin.id, version: composition.primaryPlugin.version, source: SCHEMA_DEFINED_PLUGIN_SOURCE, state: model.state };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function operationDescriptorForModel(composition, model) {
|
|
47
|
+
return {
|
|
48
|
+
plugin: composition.primaryPlugin.id,
|
|
49
|
+
version: composition.primaryPlugin.version,
|
|
50
|
+
source: SCHEMA_DEFINED_PLUGIN_SOURCE,
|
|
51
|
+
operations: Object.fromEntries(Object.entries(model.operations).map(([type, op]) => [type, { ...(op.payload || { additional: true }), authorize: op.authorize }]))
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createQueryHandler(view) {
|
|
56
|
+
if (!view || view.kind === 'state') return (_ctx, state) => cloneJson(state);
|
|
57
|
+
if (view.kind === 'collection') return (_ctx, state) => {
|
|
58
|
+
const value = getPath(state, view.path || view.collection);
|
|
59
|
+
const list = Array.isArray(value) ? value : Object.values(value || {});
|
|
60
|
+
return cloneJson(view.includeArchived === true ? list : list.filter((item) => !item.archivedAt && !item.deletedAt));
|
|
61
|
+
};
|
|
62
|
+
if (view.kind === 'count') return (_ctx, state) => {
|
|
63
|
+
const value = getPath(state, view.path || view.collection);
|
|
64
|
+
const list = Array.isArray(value) ? value : Object.values(value || {});
|
|
65
|
+
return list.filter((item) => !item.archivedAt && !item.deletedAt).length;
|
|
66
|
+
};
|
|
67
|
+
if (view.kind === 'rsvpCounts') return (_ctx, state) => {
|
|
68
|
+
const counts = { yes: 0, no: 0, maybe: 0, unset: 0 };
|
|
69
|
+
for (const guest of Object.values(state.guests || {})) counts[guest.rsvp || 'unset'] = (counts[guest.rsvp || 'unset'] || 0) + 1;
|
|
70
|
+
return counts;
|
|
71
|
+
};
|
|
72
|
+
return (_ctx, state) => cloneJson(state);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseModelState(model, state) {
|
|
76
|
+
const initial = cloneJson(model.state.initial || {});
|
|
77
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) return initial;
|
|
78
|
+
return { ...initial, ...cloneJson(state) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createSchemaDefinedHostPlugin(schema) {
|
|
82
|
+
const composition = validateAppCompositionSchema(schema);
|
|
83
|
+
const model = normalizeModel(composition);
|
|
84
|
+
const operationParsers = {};
|
|
85
|
+
for (const [type, descriptor] of Object.entries(model.operations)) operationParsers[type] = { parse: createPayloadParser(descriptor.payload || { additional: true }) };
|
|
86
|
+
const queries = {};
|
|
87
|
+
for (const [name, view] of Object.entries(model.views || {})) queries[name] = createQueryHandler(view);
|
|
88
|
+
const stateSchemaDescriptor = stateDescriptorForModel(composition, model);
|
|
89
|
+
const operationSchemaDescriptor = operationDescriptorForModel(composition, model);
|
|
90
|
+
|
|
91
|
+
const plugin = defineHostPlugin({
|
|
92
|
+
id: composition.primaryPlugin.id,
|
|
93
|
+
version: composition.primaryPlugin.version,
|
|
94
|
+
meta: { name: composition.app.name, source: SCHEMA_DEFINED_PLUGIN_SOURCE },
|
|
95
|
+
capabilities: model.capabilities || { requires: ['room.state', 'room.roles'], provides: ['schema.primary-model'] },
|
|
96
|
+
stateSchemaDescriptor,
|
|
97
|
+
operationSchemaDescriptor,
|
|
98
|
+
schemas: {
|
|
99
|
+
state: { parse: (state) => parseModelState(model, state) },
|
|
100
|
+
operations: operationParsers,
|
|
101
|
+
publicView: { parse: (view) => view }
|
|
102
|
+
},
|
|
103
|
+
createInitialState(ctx = {}) {
|
|
104
|
+
const initial = cloneJson(model.state.initial || { activity: [] });
|
|
105
|
+
if (model.state.roomIdPath) return { ...initial, [model.state.roomIdPath]: ctx.room?.id };
|
|
106
|
+
return initial;
|
|
107
|
+
},
|
|
108
|
+
authorize(_ctx, op) {
|
|
109
|
+
const descriptor = model.operations[op.type];
|
|
110
|
+
if (!descriptor) return deny(`Unsupported schema operation ${op.type}`);
|
|
111
|
+
return allow();
|
|
112
|
+
},
|
|
113
|
+
reduce(ctx, state, op) {
|
|
114
|
+
const descriptor = model.operations[op.type];
|
|
115
|
+
if (!descriptor) return state;
|
|
116
|
+
const effectContext = { operation: op, actor: ctx.actor || op.actor, payload: op.payload || {}, state, app: composition.app, room: ctx.room || { id: op.roomId } };
|
|
117
|
+
applyGuards(state, descriptor.guards || [], effectContext);
|
|
118
|
+
return applyEffects(state, descriptor.effects || [], effectContext);
|
|
119
|
+
},
|
|
120
|
+
getPublicView(_ctx, state) {
|
|
121
|
+
return cloneJson(state);
|
|
122
|
+
},
|
|
123
|
+
queries
|
|
124
|
+
});
|
|
125
|
+
plugin.schemaDefined = true;
|
|
126
|
+
plugin.schemaSource = SCHEMA_DEFINED_PLUGIN_SOURCE;
|
|
127
|
+
plugin.schemaModelHash = hashCanonical(model);
|
|
128
|
+
return plugin;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function schemaDefinedHostPluginEntry(schema, options = {}) {
|
|
132
|
+
const plugin = createSchemaDefinedHostPlugin(schema);
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
plugin,
|
|
135
|
+
packageName: options.packageName || '@mh-gg/schema',
|
|
136
|
+
exportName: 'schemaDefinedHostPlugin',
|
|
137
|
+
source: SCHEMA_DEFINED_PLUGIN_SOURCE,
|
|
138
|
+
schemaDefined: true
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
SCHEMA_DEFINED_PLUGIN_SOURCE,
|
|
144
|
+
createSchemaDefinedHostPlugin,
|
|
145
|
+
schemaDefinedHostPluginEntry
|
|
146
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { assertPlainObject, cloneJson, nonEmptyString } = require("./json.cjs");
|
|
2
|
+
|
|
3
|
+
function normalizeNotificationDefinition(definition, name) {
|
|
4
|
+
const value = assertPlainObject(definition, `notification ${name}`);
|
|
5
|
+
const on = assertPlainObject(value.on, `notification ${name}.on`);
|
|
6
|
+
const audience = assertPlainObject(value.audience, `notification ${name}.audience`);
|
|
7
|
+
const scope = assertPlainObject(value.scope, `notification ${name}.scope`);
|
|
8
|
+
const presentation = assertPlainObject(value.presentation, `notification ${name}.presentation`);
|
|
9
|
+
const read = assertPlainObject(value.read, `notification ${name}.read`);
|
|
10
|
+
const normalized = {
|
|
11
|
+
kind: nonEmptyString(value.kind, `notification ${name}.kind`),
|
|
12
|
+
on: {
|
|
13
|
+
action: nonEmptyString(on.action, `notification ${name}.on.action`),
|
|
14
|
+
...(on.plugin === undefined ? {} : { plugin: String(on.plugin) }),
|
|
15
|
+
...(on.type === undefined ? {} : { type: String(on.type) })
|
|
16
|
+
},
|
|
17
|
+
...(value.privacy === undefined ? {} : { privacy: String(value.privacy) }),
|
|
18
|
+
audience: {
|
|
19
|
+
userIds: audience.userIds,
|
|
20
|
+
...(audience.excludeActor === undefined ? {} : { excludeActor: Boolean(audience.excludeActor) })
|
|
21
|
+
},
|
|
22
|
+
scope: cloneJson(scope),
|
|
23
|
+
...(value.entity === undefined ? {} : { entity: cloneJson(value.entity) }),
|
|
24
|
+
presentation: cloneJson(presentation),
|
|
25
|
+
...(value.link === undefined ? {} : { link: cloneJson(value.link) }),
|
|
26
|
+
read: cloneJson(read),
|
|
27
|
+
...(value.delivery === undefined ? {} : { delivery: cloneJson(value.delivery) })
|
|
28
|
+
};
|
|
29
|
+
return Object.freeze(normalized);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeNotifications(notifications = {}) {
|
|
33
|
+
const value = assertPlainObject(notifications, "composition notifications");
|
|
34
|
+
const definitions = assertPlainObject(value.definitions, "composition notifications.definitions");
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
schemaVersion: Number(value.schemaVersion) || 1,
|
|
37
|
+
definitions: Object.freeze(Object.fromEntries(Object.entries(definitions).map(([name, def]) => [name, normalizeNotificationDefinition(def, name)])))
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { normalizeNotifications };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type NotificationExpression = string | { $expr: string; fallback?: string };
|
|
2
|
+
|
|
3
|
+
export type NotificationAudience = {
|
|
4
|
+
userIds: string | string[];
|
|
5
|
+
excludeActor?: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type NotificationScope = {
|
|
9
|
+
type: string;
|
|
10
|
+
id: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type NotificationEntity = {
|
|
14
|
+
type: string;
|
|
15
|
+
id: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type NotificationPresentation = {
|
|
19
|
+
title?: string | NotificationExpression;
|
|
20
|
+
body?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type NotificationLink = {
|
|
24
|
+
route: string;
|
|
25
|
+
params?: Record<string, string>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type NotificationReadPolicy = "player" | "click" | "route-visible" | "manual";
|
|
29
|
+
|
|
30
|
+
export type NotificationRead = {
|
|
31
|
+
scopeKey: string;
|
|
32
|
+
cursor: { createdAt: string; operationId: string };
|
|
33
|
+
defaultPolicy: NotificationReadPolicy;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type NotificationDelivery = {
|
|
37
|
+
collapse?: "scope" | "entity" | "none";
|
|
38
|
+
ttlSeconds?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type NotificationOn = {
|
|
42
|
+
action: string;
|
|
43
|
+
plugin?: string;
|
|
44
|
+
type?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type NotificationDef = {
|
|
48
|
+
on: NotificationOn;
|
|
49
|
+
kind: string;
|
|
50
|
+
privacy?: "encrypted" | "cleartext";
|
|
51
|
+
audience: NotificationAudience;
|
|
52
|
+
scope: NotificationScope;
|
|
53
|
+
entity?: NotificationEntity;
|
|
54
|
+
presentation: NotificationPresentation;
|
|
55
|
+
link?: NotificationLink;
|
|
56
|
+
read: NotificationRead;
|
|
57
|
+
delivery?: NotificationDelivery;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type NotificationsBlock = {
|
|
61
|
+
schemaVersion?: number;
|
|
62
|
+
definitions: Record<string, NotificationDef>;
|
|
63
|
+
};
|
package/src/registry.cjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const { createMicroPluginSchema, validateMicroPluginSchema } = require("./microPlugin.cjs");
|
|
2
|
+
const { freezeJson, nonEmptyString } = require("./json.cjs");
|
|
3
|
+
|
|
4
|
+
function createRegistryRecord(entry) {
|
|
5
|
+
const schema = createMicroPluginSchema({
|
|
6
|
+
key: entry.key,
|
|
7
|
+
id: entry.id,
|
|
8
|
+
plugin: entry.plugin,
|
|
9
|
+
packageName: entry.packageName,
|
|
10
|
+
exportName: entry.exportName,
|
|
11
|
+
source: { packageName: entry.packageName, exportName: entry.exportName, ref: `workspace:${entry.packageName}#${entry.exportName}` }
|
|
12
|
+
});
|
|
13
|
+
return Object.freeze({ ...entry, schema });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createMicroPluginRegistry(entries = []) {
|
|
17
|
+
const byKey = new Map();
|
|
18
|
+
const byId = new Map();
|
|
19
|
+
const records = [];
|
|
20
|
+
for (const raw of entries) {
|
|
21
|
+
const record = raw.schema ? raw : createRegistryRecord(raw);
|
|
22
|
+
validateMicroPluginSchema(record.schema);
|
|
23
|
+
if (byKey.has(record.schema.key)) throw new Error(`Duplicate micro plugin key ${record.schema.key}`);
|
|
24
|
+
if (byId.has(record.schema.id)) throw new Error(`Duplicate micro plugin id ${record.schema.id}`);
|
|
25
|
+
const frozen = Object.freeze(record);
|
|
26
|
+
records.push(frozen);
|
|
27
|
+
byKey.set(record.schema.key, frozen);
|
|
28
|
+
byId.set(record.schema.id, frozen);
|
|
29
|
+
for (const alias of record.aliases || []) if (!byKey.has(alias)) byKey.set(alias, frozen);
|
|
30
|
+
}
|
|
31
|
+
return Object.freeze({
|
|
32
|
+
records: Object.freeze(records.slice()),
|
|
33
|
+
schemas: freezeJson(records.map((record) => record.schema)),
|
|
34
|
+
get(selection) {
|
|
35
|
+
const key = typeof selection === "string" ? selection : selection?.key || selection?.id;
|
|
36
|
+
nonEmptyString(key, "micro plugin selection");
|
|
37
|
+
const record = byKey.get(key) || byId.get(key);
|
|
38
|
+
if (!record) throw new Error(`Unknown micro plugin ${key}`);
|
|
39
|
+
return record;
|
|
40
|
+
},
|
|
41
|
+
has(selection) {
|
|
42
|
+
try { this.get(selection); return true; } catch { return false; }
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const builtinMicroPluginRegistry = createMicroPluginRegistry();
|
|
48
|
+
|
|
49
|
+
function getMicroPluginSchema(selection) {
|
|
50
|
+
return builtinMicroPluginRegistry.get(selection).schema;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function listMicroPluginSchemas() {
|
|
54
|
+
return builtinMicroPluginRegistry.schemas;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
builtinMicroPluginRegistry,
|
|
59
|
+
createMicroPluginRegistry,
|
|
60
|
+
getMicroPluginSchema,
|
|
61
|
+
listMicroPluginSchemas,
|
|
62
|
+
reusableMicroPluginSchemas: listMicroPluginSchemas()
|
|
63
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export const APP_COMPOSITION_SCHEMA_KIND: any;
|
|
2
|
+
export const APP_COMPOSITION_SCHEMA_VERSION: any;
|
|
3
|
+
export const MICRO_PLUGIN_SCHEMA_KIND: any;
|
|
4
|
+
export const MICRO_PLUGIN_SCHEMA_VERSION: any;
|
|
5
|
+
export const SCHEMA_DEFINED_PLUGIN_SOURCE: any;
|
|
6
|
+
export const SCHEMA_ROOT: any;
|
|
7
|
+
export const SUPPORTED_SCHEMA_EFFECT_KINDS: any;
|
|
8
|
+
export const actionDescriptorsForPlugin: any;
|
|
9
|
+
export const actionDescriptorsFromOperations: any;
|
|
10
|
+
export const actionNameFromOperation: any;
|
|
11
|
+
export const appCompositionSchemaHash: any;
|
|
12
|
+
export const applyEffect: any;
|
|
13
|
+
export const applyEffects: any;
|
|
14
|
+
export const applyGuard: any;
|
|
15
|
+
export const applyGuards: any;
|
|
16
|
+
export const asArrayCollection: any;
|
|
17
|
+
export const asMapCollection: any;
|
|
18
|
+
export const assertJsonSerializable: any;
|
|
19
|
+
export const assertPlainObject: any;
|
|
20
|
+
export const assertValidJsonSchema: any;
|
|
21
|
+
export const builtinMicroPluginRegistry: any;
|
|
22
|
+
export const cloneJson: any;
|
|
23
|
+
export const collectionValue: any;
|
|
24
|
+
export const compositionPluginIds: any;
|
|
25
|
+
export const createActionDispatchersFromComposition: any;
|
|
26
|
+
export const createAppCompositionSchema: any;
|
|
27
|
+
export const createMicroPluginRegistry: any;
|
|
28
|
+
export const createMicroPluginSchema: any;
|
|
29
|
+
export const createPayloadParser: any;
|
|
30
|
+
export const createSchemaDefinedHostPlugin: any;
|
|
31
|
+
export const deepFreeze: any;
|
|
32
|
+
export const ensureRecord: any;
|
|
33
|
+
export const evaluate: any;
|
|
34
|
+
export const findRecordInArray: any;
|
|
35
|
+
export const freezeJson: any;
|
|
36
|
+
export const getMicroPluginSchema: any;
|
|
37
|
+
export const getPath: any;
|
|
38
|
+
export const guardPasses: any;
|
|
39
|
+
export const jsonArray: any;
|
|
40
|
+
export const listMicroPluginSchemas: any;
|
|
41
|
+
export const loadAppCompositionSchemaFile: any;
|
|
42
|
+
export const loadJsonWithImports: any;
|
|
43
|
+
export const microPluginSchemaHash: any;
|
|
44
|
+
export const nonEmptyString: any;
|
|
45
|
+
export const operationDescriptorsFromPlugin: any;
|
|
46
|
+
export const operationsMap: any;
|
|
47
|
+
export const optionalString: any;
|
|
48
|
+
export const parseValue: any;
|
|
49
|
+
export const pluginIdForAction: any;
|
|
50
|
+
export const readSchema: any;
|
|
51
|
+
export const recordFromCollection: any;
|
|
52
|
+
export const recordIdFrom: any;
|
|
53
|
+
export const resolveCompositionHostPluginEntries: any;
|
|
54
|
+
export const reusableMicroPluginSchemas: any;
|
|
55
|
+
export const roleMeetsAny: any;
|
|
56
|
+
export const roleRank: any;
|
|
57
|
+
export const sanitizeId: any;
|
|
58
|
+
export const schemaDefinedHostPluginEntry: any;
|
|
59
|
+
export const setPath: any;
|
|
60
|
+
export const statePathValue: any;
|
|
61
|
+
export const validateAppCompositionSchema: any;
|
|
62
|
+
export const validateJsonSchema: any;
|
|
63
|
+
export const validateMicroPluginSchema: any;
|
|
64
|
+
export const viewDescriptorsFromPlugin: any;
|
|
65
|
+
export const writeRecordToCollection: any;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type ReadClass = "window" | "head" | "full-fold";
|
|
2
|
+
export type IntegrityClass = "signature" | "seq" | "checkpoint";
|
|
3
|
+
|
|
4
|
+
export interface ShapeSpec {
|
|
5
|
+
readClass?: ReadClass;
|
|
6
|
+
integrityClass?: IntegrityClass;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function streamKey(spec: {
|
|
10
|
+
pluginId?: string;
|
|
11
|
+
collection: string;
|
|
12
|
+
scopeType?: string;
|
|
13
|
+
scopeId?: string;
|
|
14
|
+
recordId?: string;
|
|
15
|
+
}): string;
|