@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.
Files changed (68) hide show
  1. package/package.json +39 -0
  2. package/schemas/app-composition.schema.json +684 -0
  3. package/schemas/example-app.schema.json +64 -0
  4. package/schemas/example-demo.schema.json +45 -0
  5. package/schemas/micro-plugin.schema.json +20 -0
  6. package/src/actions.cjs +31 -0
  7. package/src/app.d.ts +119 -0
  8. package/src/builder/app.cjs +157 -0
  9. package/src/builder/effects.cjs +116 -0
  10. package/src/builder/guards.cjs +35 -0
  11. package/src/builder/index.cjs +11 -0
  12. package/src/builder/matterhornApp/build.cjs +137 -0
  13. package/src/builder/matterhornApp/bundle.cjs +89 -0
  14. package/src/builder/matterhornApp/demo.cjs +161 -0
  15. package/src/builder/matterhornApp/demoAliases.cjs +50 -0
  16. package/src/builder/matterhornApp/descriptors.cjs +59 -0
  17. package/src/builder/matterhornApp/exports.cjs +95 -0
  18. package/src/builder/matterhornApp/frontend.cjs +80 -0
  19. package/src/builder/matterhornApp/plugins.cjs +95 -0
  20. package/src/builder/matterhornApp/shared.cjs +105 -0
  21. package/src/builder/matterhornApp.cjs +7 -0
  22. package/src/builder/model.cjs +172 -0
  23. package/src/builder/notifications.cjs +51 -0
  24. package/src/builder/refs.cjs +33 -0
  25. package/src/builder/schema.cjs +101 -0
  26. package/src/builder/streamKey.cjs +27 -0
  27. package/src/composition.cjs +157 -0
  28. package/src/configured.cjs +41 -0
  29. package/src/configured.d.ts +1 -0
  30. package/src/imports/loader.cjs +86 -0
  31. package/src/index.cjs +12 -0
  32. package/src/index.d.ts +168 -0
  33. package/src/json.cjs +70 -0
  34. package/src/jsonSchema/validator.cjs +131 -0
  35. package/src/microPlugin.cjs +112 -0
  36. package/src/model/collections.cjs +57 -0
  37. package/src/model/effects.cjs +149 -0
  38. package/src/model/expressions.cjs +85 -0
  39. package/src/model/guards.cjs +139 -0
  40. package/src/model/index.cjs +9 -0
  41. package/src/model/partitionOperands.cjs +89 -0
  42. package/src/model/partitionValidator.cjs +95 -0
  43. package/src/model/payload.cjs +93 -0
  44. package/src/model/plugin.cjs +146 -0
  45. package/src/notifications.cjs +41 -0
  46. package/src/notifications.d.ts +63 -0
  47. package/src/registry.cjs +63 -0
  48. package/src/runtime-exports.d.ts +65 -0
  49. package/src/streamKey.d.ts +15 -0
  50. package/src/types/actionTypes.cjs +164 -0
  51. package/src/types/coreActionPayloadSchemas.cjs +35 -0
  52. package/src/types/coreFeatures.cjs +29 -0
  53. package/src/types/entities.cjs +131 -0
  54. package/src/types/entityAliases.cjs +84 -0
  55. package/src/types/generator.cjs +145 -0
  56. package/src/types/index.cjs +5 -0
  57. package/src/types/interfaceBlock.cjs +41 -0
  58. package/src/types/pluginEntities.cjs +83 -0
  59. package/src/types/schema.cjs +148 -0
  60. package/src/types/standardPluginEntityTypes.cjs +135 -0
  61. package/test/content-lww-contract.test.cjs +104 -0
  62. package/test/partitionValidator.test.cjs +92 -0
  63. package/test/schema-array-effects.test.cjs +116 -0
  64. package/test/schema-builder-types.test.cjs +383 -0
  65. package/test/schema-composition.test.cjs +144 -0
  66. package/test/schema-configured-builders.test.cjs +286 -0
  67. package/test/schema-imports.test.cjs +66 -0
  68. package/test/schema-model-interpreter.test.cjs +273 -0
@@ -0,0 +1,83 @@
1
+ const { builtinMicroPluginRegistry } = require('../registry.cjs');
2
+ const { coreStateFields, hasCoreDirectMessages, hasCoreScopedAccess } = require('./coreFeatures.cjs');
3
+ const { collectScopeTypes, pluginKeys, scopeTypeUnion, standardPluginEntityTypes } = require('./standardPluginEntityTypes.cjs');
4
+
5
+ function pluginStateShape(plugin, registry = builtinMicroPluginRegistry) {
6
+ try {
7
+ const descriptor = registry.get(plugin.key || plugin.id).schema.schemas.state;
8
+ if (Array.isArray(descriptor.shape)) return descriptor.shape;
9
+ if (descriptor.state?.initial) return Object.keys(descriptor.state.initial);
10
+ if (descriptor.state && typeof descriptor.state === 'object' && !Array.isArray(descriptor.state)) return Object.keys(descriptor.state);
11
+ } catch {
12
+ // Unknown registries are allowed in schema files; keep generation best-effort.
13
+ }
14
+ return [];
15
+ }
16
+
17
+ function typeForPluginField(pluginRef, field) {
18
+ const key = pluginRef.key || '';
19
+ if (field === 'activity') return 'Activity[]';
20
+ if (key === 'mediaRooms' && field === 'rooms') return 'Record<RoomId, MediaRoom>';
21
+ if (key === 'presence' && field === 'members') return 'Record<MemberId, PresenceMember>';
22
+ if (key === 'comments' && field === 'threads') return 'Record<ThreadId, CommentThread>';
23
+ if (key === 'comments' && field === 'comments') return 'Record<CommentId, Comment>';
24
+ if (key === 'markdown' && field === 'documents') return 'Record<string, MarkdownDocument>';
25
+ if (key === 'embeds' && field === 'embeds') return 'Record<EmbedId, Embed>';
26
+ if (key === 'files' && field === 'files') return 'Record<string, FileRecord>';
27
+ if (key === 'reactions' && field === 'reactions') return 'Record<string, ScopedReaction>';
28
+ if (key === 'screenShare' && field === 'shares') return 'Record<ShareId, ScreenShare>';
29
+ if (key === 'attachments' && field === 'attachments') return 'Record<string, Attachment>';
30
+ if (key === 'labels' && field === 'labels') return 'Record<string, Label>';
31
+ if (key === 'labels' && field === 'assignments') return 'Record<string, string[]>';
32
+ if (key === 'approvals' && field === 'requests') return 'Record<string, ApprovalRequest>';
33
+ if (key === 'checklists' && field === 'checklists') return 'Record<string, Checklist>';
34
+ if (key === 'calendar' && field === 'events') return 'Record<string, CalendarEvent>';
35
+ if (key === 'locationPins' && (field === 'pins' || field === 'locations')) return 'Record<string, LocationPin>';
36
+ if (key === 'git' && field === 'version') return 'number';
37
+ if (key === 'git' && field === 'repos') return 'Record<string, GitRepo>';
38
+ return 'unknown';
39
+ }
40
+
41
+ function flatStateField(pluginRef, field) {
42
+ const key = pluginRef.key || '';
43
+ if (field === 'activity') return undefined;
44
+ if (key === 'mediaRooms' && field === 'rooms') return { name: 'rooms', type: 'MediaRoom[]' };
45
+ if (key === 'presence' && field === 'members') return { name: 'presence', type: 'Record<MemberId, PresenceMember>' };
46
+ if (key === 'comments' && field === 'threads') return { name: 'commentThreads', type: 'Record<ThreadId, CommentThread>' };
47
+ if (key === 'comments' && field === 'comments') return { name: 'comments', type: 'Record<CommentId, Comment>' };
48
+ if (key === 'screenShare' && field === 'shares') return { name: 'screenShares', type: 'Record<ShareId, ScreenShare>' };
49
+ if (key === 'markdown' && field === 'documents') return { name: 'documents', type: 'Record<string, MarkdownDocument>' };
50
+ if (key === 'embeds' && field === 'embeds') return { name: 'embeds', type: 'Record<EmbedId, Embed>' };
51
+ if (key === 'files' && field === 'files') return { name: 'files', type: 'Record<string, FileRecord>' };
52
+ if (key === 'reactions' && field === 'reactions') return { name: 'reactions', type: 'Record<string, ScopedReaction>' };
53
+ if (key === 'attachments' && field === 'attachments') return { name: 'attachments', type: 'Record<string, Attachment>' };
54
+ if (key === 'approvals' && field === 'requests') return { name: 'approvals', type: 'Record<string, ApprovalRequest>' };
55
+ if (key === 'labels' && field === 'labels') return { name: 'labels', type: 'Record<string, Label>' };
56
+ if (key === 'labels' && field === 'assignments') return { name: 'labelAssignments', type: 'Record<string, string[]>' };
57
+ if (key === 'checklists' && field === 'checklists') return { name: 'checklists', type: 'Record<string, Checklist>' };
58
+ if (key === 'calendar' && field === 'events') return { name: 'calendarEvents', type: 'Record<string, CalendarEvent>' };
59
+ if (key === 'locationPins' && (field === 'pins' || field === 'locations')) return { name: 'locationPins', type: 'Record<string, LocationPin>' };
60
+ if (key === 'git' && field === 'version') return { name: 'version', type: 'number' };
61
+ if (key === 'git' && field === 'repos') return { name: 'repos', type: 'Record<string, GitRepo>' };
62
+ return { name: field, type: typeForPluginField(pluginRef, field) };
63
+ }
64
+
65
+ function pluginFlatStateFields(composition, registry = builtinMicroPluginRegistry) {
66
+ const fields = {};
67
+ for (const plugin of composition.plugins || []) {
68
+ for (const field of pluginStateShape(plugin, registry)) {
69
+ const flat = flatStateField(plugin, field);
70
+ if (flat) fields[flat.name] = { type: flat.type, optional: false };
71
+ }
72
+ }
73
+ return fields;
74
+ }
75
+
76
+ module.exports = {
77
+ collectScopeTypes,
78
+ coreStateFields,
79
+ hasCoreDirectMessages,
80
+ pluginFlatStateFields,
81
+ scopeTypeUnion,
82
+ standardPluginEntityTypes
83
+ };
@@ -0,0 +1,148 @@
1
+ function pascalCase(value) {
2
+ const text = String(value || 'Matterhorn');
3
+ const out = text.replace(/(^|[^a-zA-Z0-9]+)([a-zA-Z0-9])/g, (_m, _sep, char) => char.toUpperCase()).replace(/[^a-zA-Z0-9]/g, '');
4
+ return out || 'Matterhorn';
5
+ }
6
+
7
+ function namespaceNameForApp(schemaOrApp, fallback = 'MatterhornApp') {
8
+ const app = schemaOrApp?.app || schemaOrApp || {};
9
+ if (app.namespace) return pascalCase(app.namespace);
10
+ if (app.name) return pascalCase(app.name).replace(/Matterhorn|Example/g, '') || pascalCase(app.name);
11
+ if (app.id) return pascalCase(String(app.id).split('.').at(-1));
12
+ return fallback;
13
+ }
14
+
15
+ function singularize(name) {
16
+ const text = String(name || 'Item');
17
+ if (text === 'activity') return 'Activity';
18
+ if (text.endsWith('ies')) return `${text.slice(0, -3)}y`;
19
+ if (text.endsWith('Messages')) return text.slice(0, -1);
20
+ if (text.endsWith('sses')) return text.slice(0, -2);
21
+ if (text.endsWith('s') && text.length > 1) return text.slice(0, -1);
22
+ return text;
23
+ }
24
+
25
+ function typeNameForCollection(name) {
26
+ if (name === 'roleDefinitions') return 'RoleDefinition';
27
+ if (name === 'memberRoles') return 'MemberRole';
28
+ return pascalCase(singularize(name));
29
+ }
30
+
31
+ function sortedObjectEntries(value) {
32
+ return Object.entries(value || {}).sort(([a], [b]) => a.localeCompare(b));
33
+ }
34
+
35
+ function quoteProp(key) {
36
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
37
+ }
38
+
39
+ function union(values) {
40
+ const flat = [];
41
+ for (const value of values) {
42
+ for (const part of String(value || 'unknown').split(' | ')) flat.push(part.trim());
43
+ }
44
+ const unique = [...new Set(flat.filter(Boolean))];
45
+ if (unique.length === 0) return 'unknown';
46
+ if (unique.includes('unknown')) return 'unknown';
47
+ if (unique.includes('any')) return 'any';
48
+ return unique.sort().join(' | ');
49
+ }
50
+
51
+ function literalType(value) {
52
+ if (value === null) return 'null';
53
+ if (typeof value === 'string') {
54
+ if (/^[a-z0-9_.:-]+$/i.test(value) && value.includes('.')) return JSON.stringify(value);
55
+ return 'string';
56
+ }
57
+ if (typeof value === 'number') return 'number';
58
+ if (typeof value === 'boolean') return 'boolean';
59
+ if (Array.isArray(value)) return value.length ? `${union(value.map(literalType))}[]` : 'unknown[]';
60
+ if (value && typeof value === 'object') {
61
+ const entries = sortedObjectEntries(value).map(([key, child]) => `${quoteProp(key)}: ${literalType(child)}`);
62
+ return entries.length ? `{ ${entries.join('; ')} }` : 'Record<string, unknown>';
63
+ }
64
+ return 'unknown';
65
+ }
66
+
67
+ function addNull(type, spec) {
68
+ return spec?.nullable ? union([type, 'null']) : type;
69
+ }
70
+
71
+ function typeFromSchema(spec = {}) {
72
+ if (typeof spec === 'string') return spec === 'any' ? 'unknown' : spec;
73
+ if (Array.isArray(spec)) return 'string';
74
+ const type = spec.type || 'any';
75
+ if (type === 'any') return 'unknown';
76
+ if (type === 'string') return addNull('string', spec);
77
+ if (type === 'number' || type === 'integer') return addNull('number', spec);
78
+ if (type === 'boolean') return addNull('boolean', spec);
79
+ if (type === 'literal') return addNull(literalType(spec.value), spec);
80
+ if (type === 'enum') return addNull((spec.values || []).map((value) => JSON.stringify(value)).join(' | ') || 'string', spec);
81
+ if (type === 'array') return addNull(`${typeFromSchema(spec.items || { type: 'any' })}[]`, spec);
82
+ if (type === 'record') return addNull(`Record<string, ${typeFromSchema(spec.values || spec.items || { type: 'any' })}>`, spec);
83
+ if (type === 'object') return addNull(inlinePayloadType(spec), spec);
84
+ return addNull('unknown', spec);
85
+ }
86
+
87
+ function normalizePayloadSchema(schema = {}) {
88
+ if (schema.required || schema.optional) return schema;
89
+ return { additional: true };
90
+ }
91
+
92
+ function schemaEntries(part) {
93
+ if (!part) return [];
94
+ if (Array.isArray(part)) return part.map((name) => [name, { type: 'string' }]);
95
+ return Object.entries(part).map(([key, value]) => [key, typeof value === 'string' ? { type: value } : (value || { type: 'any' })]);
96
+ }
97
+
98
+ function inlinePayloadType(payloadSchema = {}) {
99
+ const schema = normalizePayloadSchema(payloadSchema);
100
+ const chunks = [];
101
+ for (const [name, spec] of schemaEntries(schema.required)) chunks.push(`${quoteProp(name)}: ${typeFromSchema(spec)}`);
102
+ for (const [name, spec] of schemaEntries(schema.optional)) chunks.push(`${quoteProp(name)}?: ${typeFromSchema(spec)}`);
103
+ if (schema.additional === true || (!schema.required && !schema.optional)) chunks.push('[key: string]: unknown');
104
+ return chunks.length ? `{ ${chunks.join('; ')} }` : 'Record<string, never>';
105
+ }
106
+
107
+ function payloadFieldInfo(payloadSchema, fieldName) {
108
+ const schema = normalizePayloadSchema(payloadSchema || {});
109
+ for (const [name, spec] of schemaEntries(schema.required)) if (name === fieldName) return { type: typeFromSchema(spec), optional: false };
110
+ for (const [name, spec] of schemaEntries(schema.optional)) if (name === fieldName) return { type: typeFromSchema(spec), optional: true };
111
+ return { type: 'unknown', optional: true };
112
+ }
113
+
114
+ function typeFromStringExpression(value, payloadSchema) {
115
+ const payloadMatch = /^\$payload\.([A-Za-z0-9_-]+)$/.exec(value);
116
+ if (payloadMatch) return payloadFieldInfo(payloadSchema, payloadMatch[1]);
117
+ if (value === '$createdAt') return { type: 'number', optional: false };
118
+ if (value.startsWith('$id:')) return { type: 'string', optional: false };
119
+ if (value === '$operation.id' || value === '$operation.type' || value === '$operation.ledgerId' || value === '$operation.snowflakeId') return { type: 'string', optional: false };
120
+ if (value.startsWith('$actor.') || value.startsWith('$app.') || value.startsWith('$room.') || value.startsWith('$profile.')) return { type: 'string', optional: false };
121
+ if (value.startsWith('$')) return { type: 'unknown', optional: true };
122
+ return { type: literalType(value), optional: false };
123
+ }
124
+
125
+ function typeFromExpression(value, payloadSchema) {
126
+ if (typeof value === 'string') return typeFromStringExpression(value, payloadSchema);
127
+ if (value && typeof value === 'object' && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, '$expr')) {
128
+ const info = typeFromStringExpression(value.$expr, payloadSchema);
129
+ if (!Object.prototype.hasOwnProperty.call(value, 'fallback')) return info;
130
+ if (Array.isArray(value.fallback) && value.fallback.length === 0 && String(info.type || '').endsWith('[]')) return { type: info.type, optional: false };
131
+ return { type: union([info.type, literalType(value.fallback)]), optional: false };
132
+ }
133
+ return { type: literalType(value), optional: false };
134
+ }
135
+
136
+ module.exports = {
137
+ inlinePayloadType,
138
+ literalType,
139
+ namespaceNameForApp,
140
+ pascalCase,
141
+ quoteProp,
142
+ schemaEntries,
143
+ sortedObjectEntries,
144
+ typeFromExpression,
145
+ typeFromSchema,
146
+ typeNameForCollection,
147
+ union
148
+ };
@@ -0,0 +1,135 @@
1
+ const { hasCoreDirectMessages, hasCoreScopedAccess } = require('./coreFeatures.cjs');
2
+
3
+ function pluginKeys(composition = {}) {
4
+ return new Set((composition.plugins || []).map((plugin) => plugin.key || plugin.id));
5
+ }
6
+
7
+ function usesScopedRole(composition = {}, keys = pluginKeys(composition)) {
8
+ return keys.has('approvals') || hasCoreScopedAccess(composition);
9
+ }
10
+
11
+ function addScopeTypesFromValue(types, value) {
12
+ if (!value || typeof value !== 'object') return;
13
+ if (Array.isArray(value)) {
14
+ for (const item of value) addScopeTypesFromValue(types, item);
15
+ return;
16
+ }
17
+ if (typeof value.scopeType === 'string') types.add(value.scopeType);
18
+ for (const item of Object.values(value)) addScopeTypesFromValue(types, item);
19
+ }
20
+
21
+ function collectScopeTypes(composition = {}) {
22
+ const types = new Set(['channel']);
23
+ for (const scope of Object.values(composition.sharedScopes || {})) {
24
+ if (typeof scope?.scopeType === 'string') types.add(scope.scopeType);
25
+ else if (typeof scope?.type === 'string') types.add(scope.type);
26
+ }
27
+ for (const action of composition.actions || []) addScopeTypesFromValue(types, action.payloadDefaults);
28
+ for (const plugin of composition.plugins || []) addScopeTypesFromValue(types, plugin.config);
29
+ return [...types].sort();
30
+ }
31
+
32
+ function scopeTypeUnion(composition = {}) {
33
+ const literals = collectScopeTypes(composition).map((type) => JSON.stringify(type));
34
+ return literals.length ? `${literals.join(' | ')} | (string & {})` : 'string & {}';
35
+ }
36
+
37
+ function standardPluginEntityTypes(composition = {}) {
38
+ const keys = pluginKeys(composition);
39
+ const lines = [` export type ChannelId = string & { readonly __brand: "ChannelId" };
40
+ export type MessageId = string & { readonly __brand: "MessageId" };
41
+ export type RoleId = string & { readonly __brand: "RoleId" };
42
+ export type RoomId = string & { readonly __brand: "RoomId" };
43
+ export type MemberId = string & { readonly __brand: "MemberId" };
44
+ export type ThreadId = string & { readonly __brand: "ThreadId" };
45
+ export type CommentId = string & { readonly __brand: "CommentId" };
46
+ export type EmbedId = string & { readonly __brand: "EmbedId" };
47
+ export type ShareId = string & { readonly __brand: "ShareId" };
48
+ export type Reactions = Record<string, MemberId[]>;
49
+ export type ScopeType = ${scopeTypeUnion(composition)};
50
+ export interface ScopeRef { scopeType: ScopeType; scopeId: string; }
51
+ export type RoleAccessGrant = "editor" | "readonly";
52
+ export type RoomRoleAccess = Record<RoleId, RoleAccessGrant>;
53
+ export type AccessLevel = "hidden" | RoleAccessGrant;
54
+ export type PresenceStatus = "online" | "idle" | "dnd" | "offline" | (string & {});
55
+ export interface MediaFlags { audio?: boolean; video?: boolean; screen?: boolean; }
56
+ export interface MessageEmbed { id?: EmbedId; url: string; provider?: string; kind?: string; title?: string; thumbnailUrl?: string; renderMode?: string; }
57
+ export interface Member { id?: MemberId; memberId?: MemberId; name?: string; displayName?: string; role?: Role | (string & {}); status?: string; avatarUrl?: string; revokedAt?: number | null; bannedAt?: number | null; }`];
58
+ if (usesScopedRole(composition, keys)) lines.push(` export type ScopedRole = "none" | "viewer" | "editor" | "moderator" | "admin" | "owner";`);
59
+ if (keys.has('mediaRooms')) lines.push(` /** From the mediaRooms plugin. */
60
+ export interface MediaRoomParticipant { memberId: MemberId; name?: string; media?: MediaFlags; joinedAt?: number; }
61
+ /** From the mediaRooms plugin. */
62
+ export interface MediaRoom { id: RoomId; name: string; group: string | null; allowsVideo: boolean; scopeType?: ScopeType; scopeId?: string; roleAccess: RoomRoleAccess; locked: boolean; spotlightMemberId?: MemberId | null; archivedAt: number | null; participants: Record<MemberId, MediaRoomParticipant>; participantCount: number; createdBy?: MemberId; createdAt?: number; }
63
+ `);
64
+ if (keys.has('presence')) lines.push(` /** From the presence plugin. */
65
+ export interface PresenceMember { memberId: MemberId; name?: string; status: PresenceStatus; activity: string | null; location: string | null; updatedAt?: number; lastPingAt?: number; visible?: boolean; avatarUrl?: string; }
66
+ `);
67
+ if (keys.has('comments')) lines.push(` /** From the comments plugin. */
68
+ export interface CommentThread { id: ThreadId; scopeType: ScopeType; scopeId: string; title: string | null; resolved: boolean; createdAt: number; lastCommentAt?: number; archivedAt: number | null; }
69
+ /** From the comments plugin. */
70
+ export interface Comment { id: CommentId; threadId: ThreadId; scopeType?: ScopeType; scopeId?: string; parentId?: CommentId | null; body: string; authorId: MemberId; authorName?: string; createdAt: number; deletedAt: number | null; reactions: Reactions; }
71
+ `);
72
+ if (keys.has('embeds')) lines.push(` /** From the embeds plugin. */
73
+ export interface Embed { id: EmbedId; scopeType: ScopeType; scopeId: string; url: string; provider?: string; kind?: string; title?: string; note: string | null; addedBy?: MemberId; addedByName?: string; addedAt: number; removedAt: number | null; }
74
+ `);
75
+ if (keys.has('files')) lines.push(` /** From the files plugin. */
76
+ export interface NostrEvent { kind: number; created_at: number; tags: string[][]; content: string; pubkey: string; id: string; sig: string; }
77
+ /** From the files plugin. */
78
+ export interface FileRecord { id: string; scopeType: ScopeType; scopeId: string; scopeKey?: string; eventId: string; event?: NostrEvent; uploadedBy?: MemberId; uploadedByName?: string; uploadedAt: number; removedAt?: number | null; removedBy?: MemberId; }
79
+ `);
80
+ if (keys.has('markdown')) lines.push(` /** From the markdown plugin. */
81
+ export interface MarkdownDocument { id: string; scopeType: ScopeType; scopeId: string; scopeKey?: string; title: string; markdown: string; ast?: unknown; embeds: MessageEmbed[]; text: string; visibility?: "public" | "members" | "private" | (string & {}); tags: string[]; updatedBy?: MemberId; updatedByName?: string; updatedAt: number; createdAt: number; createdBy?: MemberId; deletedAt?: number | null; deletedBy?: MemberId; }
82
+ `);
83
+ if (keys.has('approvals')) lines.push(` /** From the approvals plugin. */
84
+ export interface ApprovalDecision { decision: "approved" | "rejected" | "changes-requested" | (string & {}); note?: string; decidedBy?: MemberId; decidedByName?: string; decidedAt?: number; }
85
+ /** From the approvals plugin. */
86
+ export interface ApprovalRequest { id: string; scopeType: ScopeType; scopeId: string; scopeKey?: string; title: string; description?: string | null; requiredRole?: ScopedRole; assigneeIds: MemberId[]; requestedBy?: MemberId; requestedByName?: string; requestedAt: number; status: "open" | "approved" | "rejected" | "changes-requested" | (string & {}); decisions: ApprovalDecision[]; closedAt?: number; }
87
+ `);
88
+ if (hasCoreScopedAccess(composition)) lines.push(` /** From matterhorn.core scoped access. */
89
+ export interface CoreScopeAccess { scopeType: string; scopeId: string; role: ScopedRole; canView: boolean; canEdit: boolean; }
90
+ /** From matterhorn.core scoped access. */
91
+ export interface CoreAccessState { version?: number; scopes: Record<string, CoreScopeAccess>; }
92
+ `);
93
+ if (keys.has('reactions')) lines.push(` /** From the reactions plugin. */
94
+ export interface ScopedReaction { scopeType: ScopeType; scopeId: string; reactions: Reactions; updatedAt?: number; }
95
+ `);
96
+ if (keys.has('screenShare')) lines.push(` /** From the screenShare plugin. */
97
+ export interface ScreenShare { id: ShareId; scopeType?: ScopeType; scopeId?: string; roomId: RoomId | null; title: string | null; presenterId: MemberId; presenterName?: string; startedAt: number; stoppedAt: number | null; stoppedBy?: MemberId; }
98
+ `);
99
+ if (keys.has('attachments')) lines.push(` /** From the attachments plugin. */
100
+ export interface Attachment { id: string; scopeType?: ScopeType; scopeId?: string; url?: string; title?: string; mimeType?: string | null; sizeBytes?: number | null; provider?: string | null; note?: string | null; addedAt?: number; removedAt?: number | null; }
101
+ `);
102
+ if (keys.has('labels')) lines.push(` /** From the labels plugin. */
103
+ export interface Label { id: string; name: string; color?: string | null; description?: string | null; archivedAt?: number | null; }
104
+ `);
105
+ if (keys.has('checklists')) lines.push(` /** From the checklists plugin. */
106
+ export interface Checklist { id: string; scopeType?: ScopeType; scopeId?: string; title?: string; items?: Array<{ id: string; text: string; completed?: boolean; completedAt?: number | null }>; archivedAt?: number | null; }
107
+ `);
108
+ if (keys.has('calendar')) lines.push(` /** From the calendar plugin. */
109
+ export interface CalendarEvent { id: string; scopeType?: ScopeType; scopeId?: string; title: string; startsAt?: number; endsAt?: number | null; location?: string | null; description?: string | null; cancelledAt?: number | null; }
110
+ `);
111
+ if (keys.has('locationPins')) lines.push(` /** From the locationPins plugin. */
112
+ export interface LocationPin { id: string; scopeType?: ScopeType; scopeId?: string; label?: string; address?: string; lat: number; lng: number; zoom: number; createdAt?: number; updatedAt?: number; updatedBy?: MemberId; removedAt?: number | null; }
113
+ `);
114
+ if (keys.has('git')) lines.push(` /** From the git plugin. */
115
+ export type GitRole = "none" | "viewer" | "editor" | "moderator" | "admin" | "owner";
116
+ /** From the git plugin. */
117
+ export interface GitRef { name: string; oid: string | null; previousOid?: string | null; updatedBy?: MemberId; updatedByName?: string; updatedAt?: number; message?: string; source?: string; }
118
+ /** From the git plugin. */
119
+ export interface GitRepoAccess { role: GitRole; canView: boolean; canEdit: boolean; canManage: boolean; canAdmin: boolean; }
120
+ /** From the git plugin. */
121
+ export interface GitRepo { id: string; name: string; description?: string; defaultBranch?: string; defaultRole?: GitRole; currentEpoch?: number; refs?: Record<string, GitRef>; roles?: Record<MemberId, GitRole>; keyEpochs?: Record<string, { epoch: number; status?: string; createdAt?: number; wrappedKeyCount?: number }>; access?: GitRepoAccess; manifestCount?: number; chunkCount?: number; updatedAt?: number; createdAt?: number; archivedAt?: number; }
122
+ `);
123
+ if (hasCoreDirectMessages(composition)) lines.push(` /** From matterhorn.core direct messages. */
124
+ export interface DirectThread { id: ThreadId; protocol?: string; userIds: MemberId[]; topicKey?: string; topic: string | null; createdAt: number; archivedAt: number | null; }
125
+ /** From matterhorn.core direct messages. */
126
+ export interface DirectMessage { id: MessageId; protocol?: string; threadId: ThreadId; userIds?: MemberId[]; topicKey?: string; authorId: MemberId; body: string; reactions: Reactions; embeds: MessageEmbed[]; replyToId?: MessageId | null; pinnedAt?: number | null; pinnedBy?: MemberId | null; editedAt?: number | null; deletedAt?: number | null; deletedBy?: MemberId | null; createdAt: number; encrypted?: boolean; }`);
127
+ return lines.join('\n');
128
+ }
129
+
130
+ module.exports = {
131
+ collectScopeTypes,
132
+ pluginKeys,
133
+ scopeTypeUnion,
134
+ standardPluginEntityTypes
135
+ };
@@ -0,0 +1,104 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const { canonicalJson } = require("@mh-gg/event");
5
+ const { sortOperations } = require("@mh-gg/host-runtime");
6
+ const { ensureOperationIdentity, formatHlc } = require("@mh-gg/protocol");
7
+ const { applyEffects } = require("../src/model/effects.cjs");
8
+
9
+ const ACTOR = { memberId: "alice", deviceId: "dev_alice", role: "admin" };
10
+
11
+ function op(type, payload, physical, nodeId = ACTOR.deviceId) {
12
+ return ensureOperationIdentity({
13
+ roomId: "room_lww",
14
+ appPackId: "app_lww",
15
+ appPackHash: "sha256-lww",
16
+ pluginId: "plugin_lww",
17
+ type,
18
+ actor: ACTOR,
19
+ seq: physical,
20
+ createdAt: physical,
21
+ hlc: formatHlc({ physical, logical: 0, nodeId }),
22
+ payload,
23
+ auth: { credentialId: "cred", signature: "sig" }
24
+ }, { now: physical, nodeId });
25
+ }
26
+
27
+ const EFFECTS = {
28
+ "record.create": [
29
+ { kind: "createRecord", collection: "items", storage: "map", idField: "itemId", fields: { name: "$payload.name", createdAt: "$createdAt" } }
30
+ ],
31
+ "record.rename": [
32
+ { kind: "updateRecord", collection: "items", storage: "map", idField: "itemId", fields: { name: "$payload.name", updatedAt: "$createdAt" } }
33
+ ],
34
+ "card.reorder": [
35
+ { kind: "removeIdFromRecordArray", collection: "lists", storage: "array", idField: "listId", arrayField: "cardIds", value: "$payload.cardId" },
36
+ { kind: "insertIdIntoRecordArray", collection: "lists", storage: "array", idField: "listId", arrayField: "cardIds", value: "$payload.cardId", position: "$payload.position" }
37
+ ],
38
+ "path.merge": [
39
+ { kind: "mergePath", path: "settings", fields: "$payload.fields" }
40
+ ],
41
+ "reaction.toggle": [
42
+ { kind: "toggleReaction", collection: "messages", storage: "map", idField: "messageId", emojiField: "emoji" }
43
+ ]
44
+ };
45
+
46
+ function initialState() {
47
+ return {
48
+ items: { item: { id: "item", name: "initial" } },
49
+ lists: [{ id: "todo", cardIds: ["a", "b", "c"] }],
50
+ messages: { m1: { id: "m1", reactions: {} } },
51
+ settings: { theme: "light", mode: "draft" },
52
+ activity: []
53
+ };
54
+ }
55
+
56
+ function reduceOperation(state, operation) {
57
+ return applyEffects(state, EFFECTS[operation.type], { operation, actor: operation.actor, payload: operation.payload, state });
58
+ }
59
+
60
+ function rebuild(operations) {
61
+ return sortOperations(operations).reduce(reduceOperation, initialState());
62
+ }
63
+
64
+ function permutations(items) {
65
+ if (items.length <= 1) return [items];
66
+ return items.flatMap((item, index) => permutations([...items.slice(0, index), ...items.slice(index + 1)]).map((rest) => [item, ...rest]));
67
+ }
68
+
69
+ test("content replay is deterministic across arrival order and same-field edits are HLC LWW", () => {
70
+ const operations = [
71
+ op("record.rename", { itemId: "item", name: "early" }, 1000),
72
+ op("record.rename", { itemId: "item", name: "late" }, 1002),
73
+ op("path.merge", { fields: { mode: "review" } }, 1001)
74
+ ];
75
+ const expected = canonicalJson(rebuild(operations));
76
+
77
+ for (const order of permutations(operations)) assert.equal(canonicalJson(rebuild(order)), expected);
78
+ const final = rebuild([...operations].reverse());
79
+ assert.equal(final.items.item.name, "late");
80
+ assert.equal(final.settings.mode, "review");
81
+ });
82
+
83
+ test("schema list reorders converge by total-order replay for the same list", () => {
84
+ const earlyMove = op("card.reorder", { listId: "todo", cardId: "b", position: 0 }, 2000);
85
+ const lateMove = op("card.reorder", { listId: "todo", cardId: "b", position: 2 }, 2001);
86
+
87
+ const left = rebuild([earlyMove, lateMove]);
88
+ const right = rebuild([lateMove, earlyMove]);
89
+ assert.equal(canonicalJson(left), canonicalJson(right));
90
+ assert.deepEqual(left.lists[0].cardIds, ["a", "c", "b"]);
91
+ });
92
+
93
+ test("createRecord add-wins for distinct ids and toggleReaction is total-order deterministic", () => {
94
+ const createA = op("record.create", { itemId: "a", name: "A" }, 3000);
95
+ const createB = op("record.create", { itemId: "b", name: "B" }, 3001);
96
+ const toggleOn = op("reaction.toggle", { messageId: "m1", emoji: "👍" }, 3002);
97
+ const toggleOff = op("reaction.toggle", { messageId: "m1", emoji: "👍" }, 3003);
98
+
99
+ const expected = canonicalJson(rebuild([createA, createB, toggleOn, toggleOff]));
100
+ for (const order of permutations([createA, createB, toggleOn, toggleOff])) assert.equal(canonicalJson(rebuild(order)), expected);
101
+ const final = rebuild([toggleOff, createB, createA, toggleOn]);
102
+ assert.deepEqual(Object.keys(final.items).sort(), ["a", "b", "item"]);
103
+ assert.deepEqual(final.messages.m1.reactions["👍"], []);
104
+ });
@@ -0,0 +1,92 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+ const { validateModelShapes } = require("../src/model/partitionValidator.cjs");
4
+
5
+ function modelWithOperations(operations, shapes = {}) {
6
+ return { shapes, operations };
7
+ }
8
+
9
+ test("validates operations with no chunkable shapes", () => {
10
+ const issues = validateModelShapes(modelWithOperations({
11
+ createPost: {
12
+ payload: {},
13
+ effects: [{ kind: "createRecord", collection: "posts", partition: "posts" }],
14
+ guards: [{ kind: "eq", left: { path: "$state.settings.title" }, right: { kind: "value.literal", value: "x" } }]
15
+ }
16
+ }, {
17
+ posts: { readClass: "full-fold", integrityClass: "checkpoint", partition: "posts" },
18
+ settings: { readClass: "full-fold", integrityClass: "checkpoint", partition: "settings" }
19
+ }));
20
+ assert.deepEqual(issues, []);
21
+ });
22
+
23
+ test("rejects cross-partition guard on window shape", () => {
24
+ const issues = validateModelShapes(modelWithOperations({
25
+ createPost: {
26
+ payload: {},
27
+ effects: [{ kind: "createRecord", collection: "posts", partition: "posts" }],
28
+ guards: [{ kind: "eq", left: { path: "$state.settings.allowPosts" }, right: { kind: "value.literal", value: true } }]
29
+ }
30
+ }, {
31
+ posts: { readClass: "window", integrityClass: "signature", partition: "posts" },
32
+ settings: { readClass: "head", integrityClass: "seq", partition: "settings" }
33
+ }));
34
+ assert.equal(issues.length, 1);
35
+ assert.equal(issues[0].type, "cross-partition-guard");
36
+ assert.equal(issues[0].partition, "settings");
37
+ });
38
+
39
+ test("rejects cross-partition guard on head shape", () => {
40
+ const issues = validateModelShapes(modelWithOperations({
41
+ updateSettings: {
42
+ payload: {},
43
+ effects: [{ kind: "mergePath", path: "settings.title" }],
44
+ guards: [{ kind: "eq", left: { path: "$state.posts.length" }, right: { kind: "value.literal", value: 0 } }]
45
+ }
46
+ }, {
47
+ posts: { readClass: "window", integrityClass: "signature", partition: "posts" },
48
+ settings: { readClass: "head", integrityClass: "seq", partition: "settings" }
49
+ }));
50
+ assert.equal(issues.length, 1);
51
+ assert.equal(issues[0].type, "cross-partition-guard");
52
+ });
53
+
54
+ test("allows same-partition guards", () => {
55
+ const issues = validateModelShapes(modelWithOperations({
56
+ createPost: {
57
+ payload: {},
58
+ effects: [{ kind: "createRecord", collection: "posts", partition: "posts" }],
59
+ guards: [{ kind: "recordOwnerOrRole", collection: "posts", roles: ["admin"] }]
60
+ }
61
+ }, {
62
+ posts: { readClass: "window", integrityClass: "signature", partition: "posts" }
63
+ }));
64
+ assert.deepEqual(issues, []);
65
+ });
66
+
67
+ test("rejects non-append effects on window shape", () => {
68
+ const issues = validateModelShapes(modelWithOperations({
69
+ editPost: {
70
+ payload: {},
71
+ effects: [{ kind: "updateRecord", collection: "posts", partition: "posts" }],
72
+ guards: []
73
+ }
74
+ }, {
75
+ posts: { readClass: "window", integrityClass: "signature", partition: "posts" }
76
+ }));
77
+ assert.equal(issues.length, 1);
78
+ assert.equal(issues[0].type, "non-crdt-window-effect");
79
+ });
80
+
81
+ test("allows append effects on window shape", () => {
82
+ const issues = validateModelShapes(modelWithOperations({
83
+ addPost: {
84
+ payload: {},
85
+ effects: [{ kind: "createRecord", collection: "posts", partition: "posts" }],
86
+ guards: []
87
+ }
88
+ }, {
89
+ posts: { readClass: "window", integrityClass: "signature", partition: "posts" }
90
+ }));
91
+ assert.deepEqual(issues, []);
92
+ });