@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,164 @@
|
|
|
1
|
+
const { builtinMicroPluginRegistry } = require('../registry.cjs');
|
|
2
|
+
const { coreActionPayloadSchemas } = require('./coreActionPayloadSchemas.cjs');
|
|
3
|
+
const { quoteProp, schemaEntries, typeFromSchema } = require('./schema.cjs');
|
|
4
|
+
|
|
5
|
+
function isPrimaryAction(action, composition) {
|
|
6
|
+
return !action.plugin || action.plugin === 'primary' || action.plugin === '$primary' || action.plugin === composition.primaryPlugin.id;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isCoreAction(action) {
|
|
10
|
+
return action.plugin === 'core' || action.plugin === 'matterhorn.core';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function operationDescriptorForAction(action, composition, model, registry = builtinMicroPluginRegistry) {
|
|
14
|
+
if (isPrimaryAction(action, composition)) return model.operations?.[action.type];
|
|
15
|
+
if (isCoreAction(action) && coreActionPayloadSchemas[action.type]) {
|
|
16
|
+
return { payload: coreActionPayloadSchemas[action.type], authorize: action.requiredRole ? { roles: [action.requiredRole] } : undefined };
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return registry.get(action.plugin).schema.schemas.operations[action.type];
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function payloadSchemaForAction(action, composition, model, registry = builtinMicroPluginRegistry) {
|
|
26
|
+
if (action.payloadSchema) return action.payloadSchema;
|
|
27
|
+
const descriptor = operationDescriptorForAction(action, composition, model, registry);
|
|
28
|
+
return descriptor?.payload || descriptor || { additional: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function authorizationRolesForAction(action, composition, model, registry = builtinMicroPluginRegistry) {
|
|
32
|
+
const roles = action.authorize?.roles
|
|
33
|
+
|| (action.requiredRole ? [action.requiredRole] : undefined)
|
|
34
|
+
|| action.payloadSchema?.authorize?.roles
|
|
35
|
+
|| operationDescriptorForAction(action, composition, model, registry)?.authorize?.roles
|
|
36
|
+
|| payloadSchemaForAction(action, composition, model, registry)?.authorize?.roles
|
|
37
|
+
|| [];
|
|
38
|
+
return Array.isArray(roles) ? roles.filter(Boolean).map(String) : [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function unionParts(type) {
|
|
42
|
+
return String(type || 'unknown').split(' | ').map((part) => part.trim()).filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function withScalarAlias(type, alias) {
|
|
46
|
+
const parts = unionParts(type).map((part) => {
|
|
47
|
+
if (part === 'string') return alias;
|
|
48
|
+
if (part === 'string[]') return `${alias}[]`;
|
|
49
|
+
return part;
|
|
50
|
+
});
|
|
51
|
+
if (!parts.includes('null')) return [...new Set(parts)].join(' | ');
|
|
52
|
+
return [...new Set(parts.filter((part) => part !== 'null')), 'null'].join(' | ');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function typeForPayloadField(action, name, spec) {
|
|
56
|
+
if (action.type === 'comments.resolve' && name === 'resolved') return 'boolean';
|
|
57
|
+
if (action.type.startsWith('presence.') && name === 'status') return 'PresenceStatus';
|
|
58
|
+
if (action.type.startsWith('media.room.') && name === 'media') return 'MediaFlags';
|
|
59
|
+
if (action.type.startsWith('media.room.') && name === 'roleAccess') return 'RoomRoleAccess';
|
|
60
|
+
if (action.type.startsWith('message.') && name === 'embeds') return 'MessageEmbed[]';
|
|
61
|
+
if (action.type === 'file.upload' && name === 'event') return 'NostrEvent';
|
|
62
|
+
if (name === 'channelId') return withScalarAlias(typeFromSchema(spec), 'ChannelId');
|
|
63
|
+
if (name === 'messageId' || name === 'replyToId') return withScalarAlias(typeFromSchema(spec), 'MessageId');
|
|
64
|
+
if (name === 'roleId') return withScalarAlias(typeFromSchema(spec), 'RoleId');
|
|
65
|
+
if (name === 'roleIds') return withScalarAlias(typeFromSchema(spec), 'RoleId');
|
|
66
|
+
if (name === 'memberId' || name === 'userId') return withScalarAlias(typeFromSchema(spec), 'MemberId');
|
|
67
|
+
if (name.endsWith('MemberId')) return withScalarAlias(typeFromSchema(spec), 'MemberId');
|
|
68
|
+
if (name === 'userIds') return 'MemberId[]';
|
|
69
|
+
if (name === 'roomId') return withScalarAlias(typeFromSchema(spec), 'RoomId');
|
|
70
|
+
if (name === 'threadId') return withScalarAlias(typeFromSchema(spec), 'ThreadId');
|
|
71
|
+
if (name === 'commentId' || name === 'parentId') return withScalarAlias(typeFromSchema(spec), 'CommentId');
|
|
72
|
+
if (name === 'embedId') return withScalarAlias(typeFromSchema(spec), 'EmbedId');
|
|
73
|
+
if (name === 'shareId') return withScalarAlias(typeFromSchema(spec), 'ShareId');
|
|
74
|
+
if (name === 'scopeType') return withScalarAlias(typeFromSchema(spec), 'ScopeType');
|
|
75
|
+
return typeFromSchema(spec);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function typeLabelForField(spec) {
|
|
79
|
+
if (Array.isArray(spec)) return 'string';
|
|
80
|
+
if (!spec || typeof spec !== 'object') return 'string';
|
|
81
|
+
if (spec.type === 'array') return 'array';
|
|
82
|
+
if (spec.type === 'record') return 'record';
|
|
83
|
+
if (spec.type === 'object') return 'object';
|
|
84
|
+
if (spec.type === 'enum') return 'enum';
|
|
85
|
+
return spec.type || 'string';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fieldDoc(name, spec, optional = false) {
|
|
89
|
+
const constraints = [];
|
|
90
|
+
const label = typeLabelForField(spec);
|
|
91
|
+
if (spec && typeof spec === 'object') {
|
|
92
|
+
if (spec.min !== undefined) constraints.push(`>= ${spec.min}`);
|
|
93
|
+
if (spec.max !== undefined) constraints.push(`<= ${spec.max}`);
|
|
94
|
+
if (spec.nullable || spec.clearable) constraints.push('nullable');
|
|
95
|
+
if (optional && (spec.nullable || spec.clearable)) constraints.push('omit to leave unchanged; null to clear');
|
|
96
|
+
}
|
|
97
|
+
return `${name}: ${[label, ...constraints].join(', ')}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function actionPayloadDocs(action, composition, model, registry = builtinMicroPluginRegistry) {
|
|
101
|
+
const docs = [];
|
|
102
|
+
const roles = authorizationRolesForAction(action, composition, model, registry);
|
|
103
|
+
if (roles.length === 1) docs.push(`Requires \`${roles[0]}\` role.`);
|
|
104
|
+
else if (roles.length > 1) docs.push(`Requires one of ${roles.map((role) => `\`${role}\``).join(', ')} roles.`);
|
|
105
|
+
const payloadSchema = payloadSchemaForAction(action, composition, model, registry);
|
|
106
|
+
for (const [name, spec] of schemaEntries(payloadSchema.required)) {
|
|
107
|
+
docs.push(fieldDoc(name, spec));
|
|
108
|
+
}
|
|
109
|
+
for (const [name, spec] of schemaEntries(payloadSchema.optional)) {
|
|
110
|
+
docs.push(fieldDoc(name, spec, true));
|
|
111
|
+
}
|
|
112
|
+
return docs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function actionPayloadType(action, composition, model, registry = builtinMicroPluginRegistry) {
|
|
116
|
+
const payloadSchema = payloadSchemaForAction(action, composition, model, registry);
|
|
117
|
+
const required = schemaEntries(payloadSchema.required);
|
|
118
|
+
const optional = schemaEntries(payloadSchema.optional);
|
|
119
|
+
const requiredNames = new Set(required.map(([name]) => name));
|
|
120
|
+
const optionalNames = new Set(optional.map(([name]) => name));
|
|
121
|
+
const hasRequiredScope = requiredNames.has('scopeType') && requiredNames.has('scopeId');
|
|
122
|
+
const hasOptionalScope = optionalNames.has('scopeType') && optionalNames.has('scopeId');
|
|
123
|
+
const defaults = action.payloadDefaults || {};
|
|
124
|
+
const chunks = [];
|
|
125
|
+
for (const [name, spec] of required) {
|
|
126
|
+
if ((hasRequiredScope || hasOptionalScope) && (name === 'scopeType' || name === 'scopeId')) continue;
|
|
127
|
+
chunks.push(`${quoteProp(name)}${Object.prototype.hasOwnProperty.call(defaults, name) ? '?' : ''}: ${typeForPayloadField(action, name, spec)}`);
|
|
128
|
+
}
|
|
129
|
+
for (const [name, spec] of optional) {
|
|
130
|
+
if ((hasRequiredScope || hasOptionalScope) && (name === 'scopeType' || name === 'scopeId')) continue;
|
|
131
|
+
chunks.push(`${quoteProp(name)}?: ${typeForPayloadField(action, name, spec)}`);
|
|
132
|
+
}
|
|
133
|
+
const hasShape = payloadSchema.required !== undefined || payloadSchema.optional !== undefined;
|
|
134
|
+
if (payloadSchema.additional === true || !hasShape) chunks.push('[key: string]: unknown');
|
|
135
|
+
const objectType = chunks.length ? `{ ${chunks.join('; ')} }` : 'Record<string, never>';
|
|
136
|
+
if (hasRequiredScope) return objectType === 'Record<string, never>' ? 'ScopeRef' : `${objectType} & ScopeRef`;
|
|
137
|
+
if (hasOptionalScope) return objectType === 'Record<string, never>' ? 'Partial<ScopeRef>' : `${objectType} & Partial<ScopeRef>`;
|
|
138
|
+
return objectType;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function actionPayloadEntries(composition, model, registry = builtinMicroPluginRegistry) {
|
|
142
|
+
const entries = [];
|
|
143
|
+
for (const action of composition.actions || []) {
|
|
144
|
+
entries.push([
|
|
145
|
+
action.name,
|
|
146
|
+
actionPayloadType(action, composition, model, registry),
|
|
147
|
+
action.type,
|
|
148
|
+
actionPayloadDocs(action, composition, model, registry)
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
return entries;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function actionsMap(composition, model, registry = builtinMicroPluginRegistry) {
|
|
155
|
+
return Object.fromEntries(actionPayloadEntries(composition, model, registry).map(([name, payloadType]) => [name, payloadType]));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
actionPayloadEntries,
|
|
160
|
+
actionPayloadDocs,
|
|
161
|
+
actionsMap,
|
|
162
|
+
isCoreAction,
|
|
163
|
+
payloadSchemaForAction
|
|
164
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const scopedRole = { type: 'enum', values: ['none', 'viewer', 'editor', 'moderator', 'admin'] };
|
|
2
|
+
|
|
3
|
+
const coreActionPayloadSchemas = Object.freeze({
|
|
4
|
+
'dm.message': {
|
|
5
|
+
required: { userIds: { type: 'array', items: { type: 'string' } }, body: { type: 'string' } },
|
|
6
|
+
optional: { topicKey: { type: 'string' } }
|
|
7
|
+
},
|
|
8
|
+
'scope.role.set': {
|
|
9
|
+
required: { scopeType: { type: 'string' }, scopeId: { type: 'string' } },
|
|
10
|
+
optional: { target: { type: 'string' }, role: scopedRole, defaultRole: scopedRole, reason: { type: 'string' } }
|
|
11
|
+
},
|
|
12
|
+
'access.role.define': {
|
|
13
|
+
required: {
|
|
14
|
+
roleId: { type: 'string' },
|
|
15
|
+
grants: {
|
|
16
|
+
type: 'array',
|
|
17
|
+
items: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
required: { scopeType: { type: 'string' }, scopeId: { type: 'string' }, role: scopedRole }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
optional: { name: { type: 'string' }, reason: { type: 'string' } }
|
|
24
|
+
},
|
|
25
|
+
'access.role.assign': {
|
|
26
|
+
required: { target: { type: 'string' }, roleId: { type: 'string' } },
|
|
27
|
+
optional: { reason: { type: 'string' } }
|
|
28
|
+
},
|
|
29
|
+
'access.role.unassign': {
|
|
30
|
+
required: { target: { type: 'string' }, roleId: { type: 'string' } },
|
|
31
|
+
optional: { reason: { type: 'string' } }
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
module.exports = { coreActionPayloadSchemas };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function hasCoreDirectMessages(composition) {
|
|
2
|
+
return (composition.actions || []).some((action) => {
|
|
3
|
+
return (action.plugin === 'core' || action.plugin === 'matterhorn.core') && action.type === 'dm.message';
|
|
4
|
+
});
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function hasCoreScopedAccess(composition) {
|
|
8
|
+
return (composition.actions || []).some((action) => {
|
|
9
|
+
const core = action.plugin === 'core' || action.plugin === 'matterhorn.core';
|
|
10
|
+
return core && (action.type === 'scope.role.set' || String(action.type || '').startsWith('access.role.'));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function coreStateFields(composition) {
|
|
15
|
+
const fields = {};
|
|
16
|
+
if (hasCoreDirectMessages(composition)) {
|
|
17
|
+
fields.members = { type: 'Record<MemberId, Member>', optional: false };
|
|
18
|
+
fields.directThreads = { type: 'Record<ThreadId, DirectThread>', optional: false };
|
|
19
|
+
fields.directMessages = { type: 'Record<MessageId, DirectMessage>', optional: false };
|
|
20
|
+
}
|
|
21
|
+
if (hasCoreScopedAccess(composition)) fields.access = { type: 'CoreAccessState', optional: false };
|
|
22
|
+
return fields;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
coreStateFields,
|
|
27
|
+
hasCoreDirectMessages,
|
|
28
|
+
hasCoreScopedAccess
|
|
29
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const {
|
|
2
|
+
literalType,
|
|
3
|
+
sortedObjectEntries,
|
|
4
|
+
typeFromExpression,
|
|
5
|
+
typeNameForCollection
|
|
6
|
+
} = require('./schema.cjs');
|
|
7
|
+
const { finalizeEntityFields, mergeFieldInfo, recordKeyTypeForCollection } = require('./entityAliases.cjs');
|
|
8
|
+
const { interfaceBlock } = require('./interfaceBlock.cjs');
|
|
9
|
+
const {
|
|
10
|
+
coreStateFields,
|
|
11
|
+
pluginFlatStateFields,
|
|
12
|
+
standardPluginEntityTypes
|
|
13
|
+
} = require('./pluginEntities.cjs');
|
|
14
|
+
|
|
15
|
+
function fieldsFromLiteral(record) {
|
|
16
|
+
const fields = {};
|
|
17
|
+
for (const [key, value] of Object.entries(record || {})) fields[key] = { type: literalType(value), optional: value === undefined };
|
|
18
|
+
return fields;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fieldsFromEffect(effect, payloadSchema) {
|
|
22
|
+
const fields = {};
|
|
23
|
+
const updateOnly = effect.kind === 'updateRecord' || effect.kind === 'markRecord' || effect.kind === 'upsertActorRecord';
|
|
24
|
+
for (const [key, value] of Object.entries(effect.fields || {})) {
|
|
25
|
+
const info = typeFromExpression(value, payloadSchema);
|
|
26
|
+
fields[key] = updateOnly ? { ...info, optional: true, fromUpdate: true } : { ...info, optional: false };
|
|
27
|
+
}
|
|
28
|
+
return fields;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function recordCreateShape(createStats, collection, fields) {
|
|
32
|
+
const stats = createStats.get(collection) || { count: 0, fields: new Map() };
|
|
33
|
+
stats.count += 1;
|
|
34
|
+
for (const [key, info] of Object.entries(fields || {})) {
|
|
35
|
+
const field = stats.fields.get(key) || { count: 0, optional: false };
|
|
36
|
+
field.count += 1;
|
|
37
|
+
field.optional = field.optional || info.optional === true;
|
|
38
|
+
stats.fields.set(key, field);
|
|
39
|
+
}
|
|
40
|
+
createStats.set(collection, stats);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function applyCreateRequiredness(entities, createStats) {
|
|
44
|
+
for (const [collection, stats] of createStats.entries()) {
|
|
45
|
+
const fields = entities.get(collection);
|
|
46
|
+
if (!fields || !stats.count) continue;
|
|
47
|
+
for (const [key, info] of Object.entries(fields)) {
|
|
48
|
+
const created = stats.fields.get(key);
|
|
49
|
+
info.optional = !created || created.optional || created.count < stats.count;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mergeEntityFields(entities, collection, fields) {
|
|
55
|
+
const existing = entities.get(collection) || {};
|
|
56
|
+
for (const [key, info] of Object.entries(fields)) existing[key] = mergeFieldInfo(existing[key], info);
|
|
57
|
+
entities.set(collection, existing);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function arrayFieldFromEffect(effect, payloadSchema) {
|
|
61
|
+
if (!effect.arrayField || !['insertIdIntoRecordArray', 'removeIdFromRecordArray'].includes(effect.kind)) return undefined;
|
|
62
|
+
const item = typeFromExpression(effect.value, payloadSchema);
|
|
63
|
+
return { [effect.arrayField]: { type: `${item.type || 'unknown'}[]`, optional: true, fromUpdate: true } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function collectPrimaryEntities(model) {
|
|
67
|
+
const initial = model.state?.initial || {};
|
|
68
|
+
const entities = new Map();
|
|
69
|
+
const collectionStorage = new Map();
|
|
70
|
+
const createStats = new Map();
|
|
71
|
+
for (const [key, value] of Object.entries(initial)) {
|
|
72
|
+
if (key === 'activity') continue;
|
|
73
|
+
if (Array.isArray(value)) collectionStorage.set(key, 'array');
|
|
74
|
+
else if (value && typeof value === 'object') collectionStorage.set(key, 'map');
|
|
75
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0) {
|
|
76
|
+
for (const record of Object.values(value)) {
|
|
77
|
+
if (record && typeof record === 'object' && !Array.isArray(record)) {
|
|
78
|
+
const fields = fieldsFromLiteral(record);
|
|
79
|
+
recordCreateShape(createStats, key, fields);
|
|
80
|
+
mergeEntityFields(entities, key, fields);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const operation of Object.values(model.operations || {})) {
|
|
86
|
+
for (const effect of operation.effects || []) {
|
|
87
|
+
if (!effect.collection) continue;
|
|
88
|
+
const arrayField = arrayFieldFromEffect(effect, operation.payload || {});
|
|
89
|
+
if (arrayField) mergeEntityFields(entities, effect.collection, arrayField);
|
|
90
|
+
if (!['createRecord', 'updateRecord', 'markRecord', 'upsertActorRecord'].includes(effect.kind)) continue;
|
|
91
|
+
collectionStorage.set(effect.collection, effect.storage || collectionStorage.get(effect.collection) || 'map');
|
|
92
|
+
const fields = fieldsFromEffect(effect, operation.payload || {});
|
|
93
|
+
if (effect.kind === 'createRecord') {
|
|
94
|
+
fields.id = mergeFieldInfo(fields.id, { type: 'string', optional: false });
|
|
95
|
+
recordCreateShape(createStats, effect.collection, fields);
|
|
96
|
+
}
|
|
97
|
+
mergeEntityFields(entities, effect.collection, fields);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
applyCreateRequiredness(entities, createStats);
|
|
101
|
+
finalizeEntityFields(entities);
|
|
102
|
+
return { entities, collectionStorage };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function primaryStateFields(model, entityData) {
|
|
106
|
+
const initial = model.state?.initial || {};
|
|
107
|
+
const fields = {};
|
|
108
|
+
for (const [key, value] of sortedObjectEntries(initial)) {
|
|
109
|
+
if (key === 'activity') fields[key] = { type: 'Activity[]', optional: false };
|
|
110
|
+
else if (Array.isArray(value) || entityData.collectionStorage.get(key) === 'array') fields[key] = { type: entityData.entities.has(key) ? `${typeNameForCollection(key)}[]` : 'unknown[]', optional: false };
|
|
111
|
+
else if (value && typeof value === 'object') fields[key] = { type: entityData.entities.has(key) ? `Record<${recordKeyTypeForCollection(key)}, ${typeNameForCollection(key)}>` : literalType(value), optional: false };
|
|
112
|
+
else fields[key] = { type: literalType(value), optional: false };
|
|
113
|
+
}
|
|
114
|
+
return fields;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function collectionItemType(collection, entityData) {
|
|
118
|
+
if (!collection) return 'unknown';
|
|
119
|
+
if (entityData.entities.has(collection)) return typeNameForCollection(collection);
|
|
120
|
+
return 'unknown';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
collectPrimaryEntities,
|
|
125
|
+
collectionItemType,
|
|
126
|
+
coreStateFields,
|
|
127
|
+
interfaceBlock,
|
|
128
|
+
pluginFlatStateFields,
|
|
129
|
+
primaryStateFields,
|
|
130
|
+
standardPluginEntityTypes
|
|
131
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const { union } = require('./schema.cjs');
|
|
2
|
+
|
|
3
|
+
function unionParts(type) {
|
|
4
|
+
return String(type || 'unknown').split(' | ').map((part) => part.trim()).filter(Boolean);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function removeUnionPart(type, target) {
|
|
8
|
+
const parts = unionParts(type).filter((part) => part !== target);
|
|
9
|
+
return parts.length ? union(parts) : type;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hasUnionPart(type, target) {
|
|
13
|
+
return unionParts(type).includes(target);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function mergeFieldInfo(current, next) {
|
|
17
|
+
if (!current) return next;
|
|
18
|
+
if (current.type === 'unknown[]' && next.type !== 'unknown[]' && String(next.type).endsWith('[]')) {
|
|
19
|
+
return { type: next.type, optional: current.optional && next.optional };
|
|
20
|
+
}
|
|
21
|
+
if (next.type === 'unknown[]' && current.type !== 'unknown[]' && String(current.type).endsWith('[]')) {
|
|
22
|
+
return { type: current.type, optional: current.optional && next.optional };
|
|
23
|
+
}
|
|
24
|
+
const nextType = next.fromUpdate && !hasUnionPart(current.type, 'null') ? removeUnionPart(next.type, 'null') : next.type;
|
|
25
|
+
return { type: union([current.type, nextType]), optional: current.optional && next.optional };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function withStringAlias(type, alias) {
|
|
29
|
+
const out = unionParts(type).map((part) => {
|
|
30
|
+
if (part === 'string') return alias;
|
|
31
|
+
if (part === 'string[]') return `${alias}[]`;
|
|
32
|
+
return part;
|
|
33
|
+
});
|
|
34
|
+
if (!out.includes('null')) return union(out);
|
|
35
|
+
return [...out.filter((part) => part !== 'null'), 'null'].join(' | ');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function collectionIdAlias(collection) {
|
|
39
|
+
if (collection === 'channels') return 'ChannelId';
|
|
40
|
+
if (collection === 'messages') return 'MessageId';
|
|
41
|
+
if (collection === 'roleDefinitions') return 'RoleId';
|
|
42
|
+
if (collection === 'rooms') return 'RoomId';
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function entityFieldType(collection, field, type) {
|
|
47
|
+
const idAlias = field === 'id' ? collectionIdAlias(collection) : undefined;
|
|
48
|
+
if (idAlias) return withStringAlias(type, idAlias);
|
|
49
|
+
if (field === 'channelId') return withStringAlias(type, 'ChannelId');
|
|
50
|
+
if (field === 'messageId' || field === 'replyToId') return withStringAlias(type, 'MessageId');
|
|
51
|
+
if (field === 'roleId') return withStringAlias(type, 'RoleId');
|
|
52
|
+
if (field === 'roleIds') return withStringAlias(type, 'RoleId');
|
|
53
|
+
if (field === 'memberId' || field === 'authorId' || field === 'createdBy' || field === 'updatedBy' || field === 'archivedBy' || field === 'assignedBy' || field === 'deletedBy' || field === 'pinnedBy') return withStringAlias(type, 'MemberId');
|
|
54
|
+
if (field === 'roomId') return withStringAlias(type, 'RoomId');
|
|
55
|
+
if (field === 'threadId') return withStringAlias(type, 'ThreadId');
|
|
56
|
+
if (field === 'commentId' || field === 'parentId') return withStringAlias(type, 'CommentId');
|
|
57
|
+
if (field === 'embedId') return withStringAlias(type, 'EmbedId');
|
|
58
|
+
if (field === 'shareId') return withStringAlias(type, 'ShareId');
|
|
59
|
+
if (field === 'reactions') return 'Reactions';
|
|
60
|
+
if (field === 'embeds') return 'MessageEmbed[]';
|
|
61
|
+
return type;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function finalizeEntityFields(entities) {
|
|
65
|
+
for (const [collection, fields] of entities.entries()) {
|
|
66
|
+
const finalized = {};
|
|
67
|
+
for (const [key, info] of Object.entries(fields)) finalized[key] = { type: entityFieldType(collection, key, info.type), optional: info.optional };
|
|
68
|
+
entities.set(collection, finalized);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function recordKeyTypeForCollection(collection) {
|
|
73
|
+
if (collection === 'channels') return 'ChannelId';
|
|
74
|
+
if (collection === 'messages') return 'MessageId';
|
|
75
|
+
if (collection === 'roleDefinitions') return 'RoleId';
|
|
76
|
+
if (collection === 'memberRoles') return 'MemberId';
|
|
77
|
+
return 'string';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
finalizeEntityFields,
|
|
82
|
+
mergeFieldInfo,
|
|
83
|
+
recordKeyTypeForCollection
|
|
84
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const { validateAppCompositionSchema } = require('../composition.cjs');
|
|
2
|
+
const { builtinMicroPluginRegistry } = require('../registry.cjs');
|
|
3
|
+
const {
|
|
4
|
+
inlinePayloadType,
|
|
5
|
+
namespaceNameForApp,
|
|
6
|
+
pascalCase,
|
|
7
|
+
quoteProp,
|
|
8
|
+
sortedObjectEntries,
|
|
9
|
+
typeNameForCollection
|
|
10
|
+
} = require('./schema.cjs');
|
|
11
|
+
const { actionPayloadEntries, actionsMap, isCoreAction } = require('./actionTypes.cjs');
|
|
12
|
+
const { docBlock } = require('./interfaceBlock.cjs');
|
|
13
|
+
const {
|
|
14
|
+
collectPrimaryEntities,
|
|
15
|
+
collectionItemType,
|
|
16
|
+
coreStateFields,
|
|
17
|
+
interfaceBlock,
|
|
18
|
+
pluginFlatStateFields,
|
|
19
|
+
primaryStateFields,
|
|
20
|
+
standardPluginEntityTypes
|
|
21
|
+
} = require('./entities.cjs');
|
|
22
|
+
|
|
23
|
+
function operationsMap(composition, model, registry = builtinMicroPluginRegistry) {
|
|
24
|
+
const operations = {};
|
|
25
|
+
for (const [type, descriptor] of sortedObjectEntries(model.operations || {})) operations[type] = inlinePayloadType(descriptor.payload || { additional: true });
|
|
26
|
+
for (const plugin of composition.plugins || []) {
|
|
27
|
+
try {
|
|
28
|
+
const record = registry.get(plugin.key || plugin.id);
|
|
29
|
+
for (const [type, descriptor] of sortedObjectEntries(record.schema.schemas.operations || {})) operations[type] = inlinePayloadType(descriptor);
|
|
30
|
+
} catch {
|
|
31
|
+
// Unknown registries are allowed in schema files; keep generation best-effort.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return operations;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function queryResultType(view, model, entityData) {
|
|
38
|
+
if (!view) return 'unknown';
|
|
39
|
+
if (view.plugin === 'primary' || view.plugin === '$primary' || !view.plugin) {
|
|
40
|
+
const modelView = model.views?.[view.query || view.name] || view;
|
|
41
|
+
if (modelView.kind === 'collection') return `${collectionItemType(modelView.collection || modelView.path, entityData)}[]`;
|
|
42
|
+
if (modelView.kind === 'count') return 'number';
|
|
43
|
+
if (modelView.kind === 'state') return 'State';
|
|
44
|
+
return 'unknown';
|
|
45
|
+
}
|
|
46
|
+
if (view.query === 'onlineMembers') return 'PresenceMember[]';
|
|
47
|
+
if (view.query === 'roomDirectory') return 'MediaRoom[]';
|
|
48
|
+
if (view.query === 'commentsForScope') return 'Comment[]';
|
|
49
|
+
if (view.query === 'embedsForScope') return 'Embed[]';
|
|
50
|
+
if (view.query === 'reactionsForScope') return 'ScopedReaction';
|
|
51
|
+
if (view.query === 'activeShares') return 'ScreenShare[]';
|
|
52
|
+
return 'unknown';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function routeUnion(routes, field) {
|
|
56
|
+
const values = [...new Set((routes || []).map((route) => route[field]).filter(Boolean))];
|
|
57
|
+
return values.length ? values.map((value) => JSON.stringify(value)).join(' | ') : 'string';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function generatedHeader(namespace) {
|
|
61
|
+
return `/* =============================================================================\n * ${namespace}.types.d.ts — GENERATED by Matterhorn schema type generation.\n * DO NOT EDIT BY HAND.\n *\n * This is the frontend contract emitted from the room config: primary model,\n * composed plugin operations, query results, routes, and launch envelope.\n * ========================================================================== */\n\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasPlugin(composition, key) {
|
|
65
|
+
return (composition.plugins || []).some((plugin) => plugin.key === key || plugin.id === key);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasCoreDirectMessages(composition) {
|
|
69
|
+
return (composition.actions || []).some((action) => isCoreAction(action) && action.type === 'dm.message');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function coreClientMethods(composition) {
|
|
73
|
+
const methods = [];
|
|
74
|
+
if (hasPlugin(composition, 'presence')) {
|
|
75
|
+
methods.push(' sendPresence(input: { status: PresenceStatus; activity?: string; at?: number }): Promise<boolean>;');
|
|
76
|
+
}
|
|
77
|
+
return methods;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function appContractDeclaration(namespace, hasDirectMessages) {
|
|
81
|
+
const lines = [`export interface ${namespace} {`];
|
|
82
|
+
lines.push(` State: ${namespace}.State;`);
|
|
83
|
+
lines.push(` Actions: ${namespace}.Actions;`);
|
|
84
|
+
lines.push(` Queries: ${namespace}.Queries;`);
|
|
85
|
+
lines.push(` QueryInput: ${namespace}.QueryInput;`);
|
|
86
|
+
lines.push(` Actor: ${namespace}.Actor;`);
|
|
87
|
+
lines.push(` Core: ${namespace}.Core;`);
|
|
88
|
+
lines.push(` LaunchEnvelope: ${namespace}.LaunchEnvelope;`);
|
|
89
|
+
if (hasDirectMessages) lines.push(` DirectMessageThread: ${namespace}.DirectMessageThread;`);
|
|
90
|
+
lines.push('}');
|
|
91
|
+
return lines.join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function generateAppTypeDeclaration(schema, options = {}) {
|
|
95
|
+
const registry = options.registry || builtinMicroPluginRegistry;
|
|
96
|
+
const composition = validateAppCompositionSchema(schema, { registry });
|
|
97
|
+
const namespace = pascalCase(options.namespace || namespaceNameForApp(composition));
|
|
98
|
+
const model = composition.primaryPlugin.model || { state: { initial: {} }, operations: {}, views: {} };
|
|
99
|
+
const entityData = collectPrimaryEntities(model);
|
|
100
|
+
const lines = [generatedHeader(namespace), `export namespace ${namespace} {`];
|
|
101
|
+
const roleIds = Object.keys(model.state?.initial?.roleDefinitions || {});
|
|
102
|
+
lines.push(` export type Role = ${roleIds.length ? roleIds.map((role) => JSON.stringify(role)).join(' | ') : 'string'};`);
|
|
103
|
+
lines.push(standardPluginEntityTypes(composition));
|
|
104
|
+
lines.push(' export interface Actor { memberId: MemberId; deviceId: string; role: Role | (string & {}); displayName?: string; avatar?: string; avatarUrl?: string; profileImageUrl?: string; }');
|
|
105
|
+
lines.push(' export interface Activity { id: string; operationId?: string; actorId?: MemberId; actorName?: string; message: string; createdAt?: number; }');
|
|
106
|
+
|
|
107
|
+
for (const [collection, fields] of [...entityData.entities.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
108
|
+
lines.push(interfaceBlock(typeNameForCollection(collection), fields, ' ', `From primary model collection \`${collection}\`.`));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
lines.push(interfaceBlock('State', { ...primaryStateFields(model, entityData), ...pluginFlatStateFields(composition, registry), ...coreStateFields(composition) }));
|
|
112
|
+
if (hasCoreDirectMessages(composition)) lines.push(' export interface DirectMessageThread { thread: DirectThread; messages: DirectMessage[]; }');
|
|
113
|
+
const actionEntries = actionPayloadEntries(composition, model, registry);
|
|
114
|
+
lines.push(' export interface Actions {');
|
|
115
|
+
for (const [name, payloadType, _operationType, docs] of actionEntries) {
|
|
116
|
+
const actionDocs = docBlock(docs, ' ');
|
|
117
|
+
if (actionDocs) lines.push(actionDocs);
|
|
118
|
+
lines.push(` ${quoteProp(name)}: ${payloadType};`);
|
|
119
|
+
}
|
|
120
|
+
lines.push(' }');
|
|
121
|
+
lines.push(' export type ActionName = keyof Actions;');
|
|
122
|
+
lines.push(' export type ActionPayload<K extends ActionName> = Actions[K];');
|
|
123
|
+
lines.push(' export type OperationType = {');
|
|
124
|
+
for (const [name, _payloadType, operationType] of actionEntries) lines.push(` ${quoteProp(name)}: ${JSON.stringify(operationType)};`);
|
|
125
|
+
lines.push(' };');
|
|
126
|
+
|
|
127
|
+
lines.push(' export interface Queries {');
|
|
128
|
+
for (const view of composition.views || []) lines.push(` ${quoteProp(view.name)}: ${queryResultType(view, model, entityData)};`);
|
|
129
|
+
lines.push(' }');
|
|
130
|
+
lines.push(' export type QueryName = keyof Queries;');
|
|
131
|
+
lines.push(' export interface QueryInput {');
|
|
132
|
+
for (const view of composition.views || []) lines.push(` ${quoteProp(view.name)}: void;`);
|
|
133
|
+
lines.push(' }');
|
|
134
|
+
lines.push(` export type RoutePath = ${routeUnion(composition.routes, 'path')};`);
|
|
135
|
+
lines.push(` export type RouteComponent = ${routeUnion(composition.routes, 'component')};`);
|
|
136
|
+
lines.push(' export interface Core {');
|
|
137
|
+
for (const method of coreClientMethods(composition)) lines.push(method);
|
|
138
|
+
lines.push(' }');
|
|
139
|
+
lines.push(' export interface LaunchEnvelope { room?: { id?: string; name?: string }; actor?: Actor; credentialGrant?: { credentialId?: string }; initialState?: State; }');
|
|
140
|
+
lines.push('}');
|
|
141
|
+
lines.push(appContractDeclaration(namespace, hasCoreDirectMessages(composition)));
|
|
142
|
+
return `${lines.join('\n')}\n`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { actionsMap, generateAppTypeDeclaration, operationsMap, queryResultType };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { quoteProp, sortedObjectEntries } = require('./schema.cjs');
|
|
2
|
+
|
|
3
|
+
function docBlock(doc, indent = ' ') {
|
|
4
|
+
if (!doc) return '';
|
|
5
|
+
const lines = Array.isArray(doc) ? doc.filter(Boolean) : [doc];
|
|
6
|
+
if (lines.length === 0) return '';
|
|
7
|
+
return [
|
|
8
|
+
`${indent}/**`,
|
|
9
|
+
...lines.map((line) => `${indent} * ${line}`),
|
|
10
|
+
`${indent} */`
|
|
11
|
+
].join('\n');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fieldDocs(info) {
|
|
15
|
+
const docs = [];
|
|
16
|
+
if (info.docs) docs.push(...(Array.isArray(info.docs) ? info.docs : [info.docs]));
|
|
17
|
+
if (info.doc) docs.push(info.doc);
|
|
18
|
+
if (info.optional && String(info.type).split(' | ').includes('null')) docs.push('omit to leave unchanged; null to clear.');
|
|
19
|
+
return docs.filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function interfaceBlock(name, fields, indent = ' ', doc) {
|
|
23
|
+
const lines = [];
|
|
24
|
+
const blockDoc = docBlock(doc, indent);
|
|
25
|
+
if (blockDoc) lines.push(blockDoc);
|
|
26
|
+
lines.push(`${indent}export interface ${name} {`);
|
|
27
|
+
const entries = sortedObjectEntries(fields);
|
|
28
|
+
if (entries.length === 0) lines.push(`${indent} [key: string]: unknown;`);
|
|
29
|
+
for (const [key, info] of entries) {
|
|
30
|
+
const docs = docBlock(fieldDocs(info), `${indent} `);
|
|
31
|
+
if (docs) lines.push(docs);
|
|
32
|
+
lines.push(`${indent} ${quoteProp(key)}${info.optional ? '?' : ''}: ${info.type};`);
|
|
33
|
+
}
|
|
34
|
+
lines.push(`${indent}}`);
|
|
35
|
+
return lines.join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
docBlock,
|
|
40
|
+
interfaceBlock
|
|
41
|
+
};
|