@artkit-ai/core 0.1.0 → 0.2.0

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 (87) hide show
  1. package/dist/adapter.d.ts +119 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +45 -0
  4. package/dist/adapter.js.map +1 -0
  5. package/dist/diff.d.ts +1 -1
  6. package/dist/diff.d.ts.map +1 -1
  7. package/dist/engine.d.ts +29 -23
  8. package/dist/engine.d.ts.map +1 -1
  9. package/dist/engine.js +148 -197
  10. package/dist/engine.js.map +1 -1
  11. package/dist/envelope.d.ts +10 -27
  12. package/dist/envelope.d.ts.map +1 -1
  13. package/dist/envelope.js +8 -20
  14. package/dist/envelope.js.map +1 -1
  15. package/dist/errors.d.ts +1 -1
  16. package/dist/errors.d.ts.map +1 -1
  17. package/dist/errors.js +4 -4
  18. package/dist/errors.js.map +1 -1
  19. package/dist/events.d.ts +2 -10
  20. package/dist/events.d.ts.map +1 -1
  21. package/dist/events.js.map +1 -1
  22. package/dist/i18n.d.ts +8 -14
  23. package/dist/i18n.d.ts.map +1 -1
  24. package/dist/i18n.js +12 -18
  25. package/dist/i18n.js.map +1 -1
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/logger.d.ts +1 -2
  31. package/dist/logger.d.ts.map +1 -1
  32. package/dist/logger.js.map +1 -1
  33. package/dist/memory.d.ts +0 -2
  34. package/dist/memory.d.ts.map +1 -1
  35. package/dist/memory.js +15 -109
  36. package/dist/memory.js.map +1 -1
  37. package/dist/migrations.d.ts +6 -5
  38. package/dist/migrations.d.ts.map +1 -1
  39. package/dist/migrations.js +20 -14
  40. package/dist/migrations.js.map +1 -1
  41. package/dist/projection.d.ts +22 -6
  42. package/dist/projection.d.ts.map +1 -1
  43. package/dist/projection.js +5 -5
  44. package/dist/projection.js.map +1 -1
  45. package/dist/registry.d.ts +17 -16
  46. package/dist/registry.d.ts.map +1 -1
  47. package/dist/registry.js +25 -25
  48. package/dist/registry.js.map +1 -1
  49. package/dist/service.d.ts +19 -51
  50. package/dist/service.d.ts.map +1 -1
  51. package/dist/service.js +119 -404
  52. package/dist/service.js.map +1 -1
  53. package/dist/store.d.ts +12 -61
  54. package/dist/store.d.ts.map +1 -1
  55. package/dist/testing/index.d.ts +10 -9
  56. package/dist/testing/index.d.ts.map +1 -1
  57. package/dist/testing/index.js +75 -136
  58. package/dist/testing/index.js.map +1 -1
  59. package/dist/theme.js +1 -1
  60. package/dist/ui.d.ts +18 -1
  61. package/dist/ui.d.ts.map +1 -1
  62. package/dist/ui.js +5 -3
  63. package/dist/ui.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/adapter.ts +180 -0
  66. package/src/diff.ts +1 -1
  67. package/src/engine.ts +183 -230
  68. package/src/envelope.ts +11 -29
  69. package/src/errors.ts +4 -4
  70. package/src/events.ts +2 -11
  71. package/src/i18n.ts +14 -20
  72. package/src/index.ts +2 -4
  73. package/src/logger.ts +1 -2
  74. package/src/memory.ts +16 -122
  75. package/src/migrations.ts +24 -18
  76. package/src/projection.ts +25 -7
  77. package/src/registry.ts +45 -41
  78. package/src/service.ts +144 -540
  79. package/src/store.ts +12 -64
  80. package/src/testing/index.ts +89 -164
  81. package/src/theme.ts +1 -1
  82. package/src/ui.ts +24 -4
  83. package/dist/kind.d.ts +0 -156
  84. package/dist/kind.d.ts.map +0 -1
  85. package/dist/kind.js +0 -52
  86. package/dist/kind.js.map +0 -1
  87. package/src/kind.ts +0 -217
package/src/engine.ts CHANGED
@@ -1,14 +1,13 @@
1
1
  import { z } from 'zod';
2
+ import type { AdapterContext, AnyFormatAdapter, ChangeSummary, LintIssue } from './adapter.js';
2
3
  import { diffFields } from './diff.js';
3
4
  import { type Artifact, localeSchema, titleSchema } from './envelope.js';
4
5
  import { ArtifactError, type ErrorIssue, formatPath, zodIssuesToErrorIssues } from './errors.js';
5
6
  import { t } from './i18n.js';
6
- import { findDuplicateIds, IdAllocator } from './ids.js';
7
- import type { AnyArtifactKind, ApplyContext, ChangeSummary, IdContext, LintIssue } from './kind.js';
8
7
  import type { ArtifactRegistry } from './registry.js';
9
8
  import { jsonByteSize, jsonDepth, MAX_INPUT_DEPTH, sanitizeDeep } from './sanitize.js';
10
9
 
11
- /** Envelope operations, available for every kind and handled by the core (spec §8.4). */
10
+ /** Envelope operations, available for every format and handled by the core (spec §9.3). */
12
11
  export const envelopeOperationSchema = z.discriminatedUnion('op', [
13
12
  z.object({ op: z.literal('set_title'), title: titleSchema }).strict(),
14
13
  z.object({ op: z.literal('set_theme'), themeId: z.string().min(1).max(64) }).strict(),
@@ -46,88 +45,52 @@ export function operationNames(schema: z.ZodType): string[] {
46
45
  return names;
47
46
  }
48
47
 
49
- export interface PreparedContent<Content = unknown> {
50
- content: Content;
51
- ids: string[];
52
- lint: LintIssue[];
53
- }
54
-
55
- export interface EngineOptions {
56
- locale?: string;
57
- }
58
-
59
- function idContext(allocator: IdAllocator): IdContext {
60
- return {
61
- nextId: (prefix) => allocator.next(prefix),
62
- hasId: (id) => allocator.has(id),
63
- reserveId: (id) => allocator.reserve(id),
64
- };
65
- }
66
-
67
48
  function checkDepth(value: unknown, path: string, locale?: string): void {
68
49
  if (jsonDepth(value) > MAX_INPUT_DEPTH) {
69
- throw new ArtifactError(
70
- 'LIMIT_EXCEEDED',
71
- t(locale, 'LIMIT_EXCEEDED', { limit: 'depth', max: MAX_INPUT_DEPTH, found: `>${MAX_INPUT_DEPTH}` }),
72
- {
73
- issues: [
74
- {
75
- path,
76
- message: t(locale, 'LIMIT_EXCEEDED', {
77
- limit: 'depth',
78
- max: MAX_INPUT_DEPTH,
79
- found: `>${MAX_INPUT_DEPTH}`,
80
- }),
81
- },
82
- ],
83
- details: { limit: 'depth', max: MAX_INPUT_DEPTH },
84
- },
85
- );
50
+ const message = t(locale, 'LIMIT_EXCEEDED', {
51
+ limit: 'depth',
52
+ max: MAX_INPUT_DEPTH,
53
+ found: `>${MAX_INPUT_DEPTH}`,
54
+ });
55
+ throw new ArtifactError('LIMIT_EXCEEDED', message, {
56
+ issues: [{ path, message }],
57
+ details: { limit: 'depth', max: MAX_INPUT_DEPTH },
58
+ });
86
59
  }
87
60
  }
88
61
 
89
- /** Schema + integrity + unique ids + limits. Throws INVALID_CONTENT or LIMIT_EXCEEDED. */
62
+ /** Adapter validation + limits. Throws INVALID_CONTENT or LIMIT_EXCEEDED. Returns the normalized content. */
90
63
  export function validateContent(
91
64
  registry: ArtifactRegistry,
92
- kind: AnyArtifactKind,
65
+ adapter: AnyFormatAdapter,
93
66
  raw: unknown,
94
- locale?: string,
67
+ ctx: AdapterContext,
95
68
  ): unknown {
96
- const parsed = kind.contentSchema.safeParse(raw);
97
- if (!parsed.success) {
98
- throw new ArtifactError('INVALID_CONTENT', t(locale, 'INVALID_CONTENT'), {
99
- issues: zodIssuesToErrorIssues(parsed.error.issues, locale, 'content'),
100
- });
101
- }
102
- // The parsed value carries schema defaults, so stored content is always normalized.
103
- const content = parsed.data;
104
- const issues: ErrorIssue[] = [...(kind.validate?.(content, locale) ?? [])].map((issue) => ({
105
- ...issue,
106
- path: issue.path.startsWith('content') ? issue.path : issue.path ? `content.${issue.path}` : 'content',
107
- }));
108
- for (const id of findDuplicateIds(kind.ids(content))) {
109
- // Kinds may already report duplicates with a precise path; don't repeat them.
110
- if (issues.some((issue) => issue.message.includes(`"${id}"`) || issue.message.includes(`'${id}'`)))
111
- continue;
112
- issues.push({
113
- path: 'content',
114
- message: t(locale, 'DUPLICATE_ID', { id }),
115
- hint: t(locale, 'DUPLICATE_ID_HINT'),
69
+ const result = adapter.validate(raw, ctx);
70
+ if (!result.ok) {
71
+ throw new ArtifactError('INVALID_CONTENT', t(ctx.locale, 'INVALID_CONTENT'), {
72
+ issues: result.issues.map((issue) => ({
73
+ ...issue,
74
+ path: issue.path.startsWith('content')
75
+ ? issue.path
76
+ : issue.path
77
+ ? `content.${issue.path}`
78
+ : 'content',
79
+ })),
116
80
  });
117
81
  }
118
- if (issues.length > 0) throw new ArtifactError('INVALID_CONTENT', t(locale, 'INVALID_CONTENT'), { issues });
119
- checkLimits(registry, kind, content, locale);
120
- return content;
82
+ checkLimits(registry, adapter, result.content, ctx.locale);
83
+ return result.content;
121
84
  }
122
85
 
123
86
  export function checkLimits(
124
87
  registry: ArtifactRegistry,
125
- kind: AnyArtifactKind,
88
+ adapter: AnyFormatAdapter,
126
89
  content: unknown,
127
90
  locale?: string,
128
91
  ): void {
129
- const limits = registry.limits(kind.kind);
130
- const measured = kind.measure?.(content) ?? {};
92
+ const limits = registry.limits(adapter.format);
93
+ const measured = adapter.measure?.(content) ?? {};
131
94
  for (const [key, max] of Object.entries(limits)) {
132
95
  const found = measured[key];
133
96
  if (found !== undefined && found > max) {
@@ -140,62 +103,55 @@ export function checkLimits(
140
103
  }
141
104
  }
142
105
 
106
+ export interface PreparedContent<Content = unknown> {
107
+ content: Content;
108
+ lint: LintIssue[];
109
+ }
110
+
143
111
  /**
144
112
  * Turns raw content (from the model, a user or an alternative input) into valid content:
145
- * sanitize → normalize ids → derive → validate → limits. Pure.
113
+ * sanitize → validate → limits → lint. Pure.
146
114
  */
147
115
  export function prepareContent(
148
116
  registry: ArtifactRegistry,
149
- kindName: string,
117
+ formatName: string,
150
118
  raw: unknown,
151
- options: EngineOptions & { theme?: string } = {},
119
+ options: { locale?: string; themeId?: string } = {},
152
120
  ): PreparedContent {
153
- const locale = options.locale;
154
- const kind = registry.get(kindName, locale);
121
+ const locale = options.locale ?? 'en';
122
+ const adapter = registry.get(formatName, locale);
123
+ const theme = registry.theme(options.themeId ?? 'default', locale);
155
124
  checkDepth(raw, 'content', locale);
156
- const sanitized = sanitizeDeep(raw);
157
- const allocator = new IdAllocator(collectExplicitIds(sanitized));
158
- let content = kind.normalize
159
- ? kind.normalize(structuredClone(sanitized), idContext(allocator))
160
- : structuredClone(sanitized);
161
- const parsed = kind.contentSchema.safeParse(content);
162
- if (!parsed.success) {
163
- throw new ArtifactError('INVALID_CONTENT', t(locale, 'INVALID_CONTENT'), {
164
- issues: zodIssuesToErrorIssues(parsed.error.issues, locale, 'content'),
125
+ const ctx: AdapterContext = { locale, theme, fail: failIn(locale, 0) };
126
+ const content = validateContent(registry, adapter, sanitizeDeep(raw), ctx);
127
+ return { content, lint: adapter.lint(content as never, { locale, theme }) };
128
+ }
129
+
130
+ function failIn(locale: string, index: number) {
131
+ return (message: string, options?: { hint?: string; path?: string }): never => {
132
+ throw new ArtifactError('OPERATION_FAILED', t(locale, 'OPERATION_FAILED', { index }), {
133
+ issues: [
134
+ {
135
+ path: options?.path ? `operations[${index}].${options.path}` : `operations[${index}]`,
136
+ message,
137
+ ...(options?.hint ? { hint: options.hint } : {}),
138
+ },
139
+ ],
140
+ details: { index },
165
141
  });
166
- }
167
- content = parsed.data;
168
- kind.derive?.(content);
169
- content = validateContent(registry, kind, content, locale);
170
- const theme = registry.theme(options.theme ?? 'default', locale);
171
- return {
172
- content,
173
- ids: [...kind.ids(content)],
174
- lint: kind.lint(content, { locale: locale ?? 'en', theme }),
175
142
  };
176
143
  }
177
144
 
178
- /** Collects string values of `id` fields anywhere in raw input, so generated ids never collide with them. */
179
- function collectExplicitIds(value: unknown, out: string[] = [], depth = 0): string[] {
180
- if (depth > MAX_INPUT_DEPTH) return out;
181
- if (Array.isArray(value)) for (const item of value) collectExplicitIds(item, out, depth + 1);
182
- else if (value !== null && typeof value === 'object') {
183
- for (const [key, item] of Object.entries(value)) {
184
- if (key === 'id' && typeof item === 'string') out.push(item);
185
- else collectExplicitIds(item, out, depth + 1);
186
- }
187
- }
188
- return out;
189
- }
145
+ export type ParsedOperation = { kind: 'envelope'; op: EnvelopeOperation } | { kind: 'content'; op: unknown };
190
146
 
191
147
  export interface BatchResult<Content = unknown> {
192
148
  content: Content;
193
- schemaVersion: number;
149
+ formatVersion: string;
194
150
  title: string;
195
151
  themeId: string;
196
152
  locale: string;
197
- /** Ids that exist after the batch and did not exist before. */
198
- createdIds: string[];
153
+ /** Anchors, declarations, ranges or sheets created by the batch. */
154
+ createdRefs: string[];
199
155
  diff: ChangeSummary[];
200
156
  lint: LintIssue[];
201
157
  }
@@ -203,11 +159,11 @@ export interface BatchResult<Content = unknown> {
203
159
  /** Parses and sanitizes a batch. Throws INVALID_OPERATION with the index of the first bad operation. */
204
160
  export function parseOperations(
205
161
  registry: ArtifactRegistry,
206
- kindName: string,
162
+ formatName: string,
207
163
  operations: unknown,
208
164
  locale?: string,
209
- ): unknown[] {
210
- const kind = registry.get(kindName, locale);
165
+ ): ParsedOperation[] {
166
+ const adapter = registry.get(formatName, locale);
211
167
  if (!Array.isArray(operations)) {
212
168
  throw new ArtifactError('INVALID_OPERATION', t(locale, 'INVALID_OPERATIONS'), {
213
169
  issues: [
@@ -243,12 +199,20 @@ export function parseOperations(
243
199
  });
244
200
  }
245
201
  checkDepth(operations, 'operations', locale);
246
- const names = [...ENVELOPE_OPERATIONS, ...operationNames(kind.operationSchema)];
202
+ const names = [...ENVELOPE_OPERATIONS, ...operationNames(adapter.operationSchema)];
247
203
  return operations.map((raw, index) => {
248
204
  const op = sanitizeDeep(raw);
249
- const schema: z.ZodType = isEnvelopeOperation(op) ? envelopeOperationSchema : kind.operationSchema;
250
- const parsed = schema.safeParse(op);
251
- if (parsed.success) return parsed.data;
205
+ if (isEnvelopeOperation(op)) {
206
+ const parsed = envelopeOperationSchema.safeParse(op);
207
+ if (parsed.success) return { kind: 'envelope', op: parsed.data };
208
+ throw invalidOperation(
209
+ index,
210
+ zodIssuesToErrorIssues(parsed.error.issues, locale, `operations[${index}]`),
211
+ locale,
212
+ );
213
+ }
214
+ const parsed = adapter.operationSchema.safeParse(op);
215
+ if (parsed.success) return { kind: 'content', op: parsed.data };
252
216
  const issues = zodIssuesToErrorIssues(parsed.error.issues, locale, `operations[${index}]`);
253
217
  const opName = (op as { op?: unknown } | null)?.op;
254
218
  if (typeof opName !== 'string' || !names.includes(opName)) {
@@ -258,10 +222,14 @@ export function parseOperations(
258
222
  hint: t(locale, 'ZOD_INVALID_VALUE_HINT', { values: names.join(', ') }),
259
223
  });
260
224
  }
261
- throw new ArtifactError('INVALID_OPERATION', t(locale, 'INVALID_OPERATION', { index }), {
262
- issues: dedupeIssues(issues),
263
- details: { index },
264
- });
225
+ throw invalidOperation(index, dedupeIssues(issues), locale);
226
+ });
227
+ }
228
+
229
+ function invalidOperation(index: number, issues: ErrorIssue[], locale?: string): ArtifactError {
230
+ return new ArtifactError('INVALID_OPERATION', t(locale, 'INVALID_OPERATION', { index }), {
231
+ issues,
232
+ details: { index },
265
233
  });
266
234
  }
267
235
 
@@ -276,82 +244,75 @@ function dedupeIssues(issues: ErrorIssue[]): ErrorIssue[] {
276
244
  }
277
245
 
278
246
  /**
279
- * Applies a batch atomically and purely (spec §8.2 steps 4–8): validate every operation, clone,
280
- * apply in sequence, derive, revalidate the whole content and limits, then diff and lint.
281
- * Any failure throws and leaves the input untouched.
247
+ * Applies a batch atomically (spec §9.1, steps 4–7). Operations run in order: runs of content operations go to the
248
+ * adapter, envelope operations and `replace_content` are handled here. Any failure throws and leaves the input
249
+ * untouched, since everything works on copies.
282
250
  */
283
- export function applyOperations(
251
+ export async function applyOperations(
284
252
  registry: ArtifactRegistry,
285
- artifact: Pick<Artifact, 'kind' | 'content' | 'title' | 'themeId' | 'locale'>,
253
+ artifact: Pick<Artifact, 'format' | 'content' | 'title' | 'themeId' | 'locale'>,
286
254
  operations: unknown,
287
- options: EngineOptions = {},
288
- ): BatchResult {
255
+ options: { locale?: string } = {},
256
+ ): Promise<BatchResult> {
289
257
  const locale = options.locale ?? artifact.locale;
290
- const kind = registry.get(artifact.kind, locale);
291
- const parsedOps = parseOperations(registry, artifact.kind, operations, locale);
258
+ const adapter = registry.get(artifact.format, locale);
259
+ const parsed = parseOperations(registry, artifact.format, operations, locale);
292
260
 
293
261
  const before = artifact.content;
294
- let draft: unknown = structuredClone(before);
295
262
  const envelope = { title: artifact.title, themeId: artifact.themeId, locale: artifact.locale };
296
- const beforeIds = new Set<string>(kind.ids(before));
297
- const allocator = new IdAllocator(beforeIds);
263
+ let draft: unknown = structuredClone(before);
264
+ const createdRefs: string[] = [];
265
+ const warnings: LintIssue[] = [];
298
266
 
299
- parsedOps.forEach((op, index) => {
300
- // `hasId` reflects the draft as it is now, so an id removed earlier in the batch can be reused
301
- // explicitly; `nextId` still never hands out an id seen in this batch, keeping generated ids stable.
302
- const present = new Set<string>(kind.ids(draft));
303
- const ctx: ApplyContext = {
304
- nextId: (prefix) => {
305
- const id = allocator.next(prefix);
306
- present.add(id);
307
- return id;
308
- },
309
- hasId: (id) => present.has(id),
310
- reserveId: (id) => {
311
- allocator.reserve(id);
312
- present.add(id);
313
- },
314
- locale,
315
- fail(message, failOptions) {
316
- throw new ArtifactError('OPERATION_FAILED', t(locale, 'OPERATION_FAILED', { index }), {
267
+ let index = 0;
268
+ while (index < parsed.length) {
269
+ const item = parsed[index];
270
+ if (item?.kind === 'envelope') {
271
+ const envelopeOp = item.op;
272
+ if (envelopeOp.op === 'replace_content') {
273
+ checkDepth(envelopeOp.content, `operations[${index}].content`, locale);
274
+ draft = replaceContent(registry, adapter, sanitizeDeep(envelopeOp.content), locale, index);
275
+ } else {
276
+ applyEnvelopeOperation(registry, envelope, envelopeOp, index, locale);
277
+ }
278
+ index += 1;
279
+ continue;
280
+ }
281
+ // Consecutive content operations form one run for the adapter.
282
+ const run: unknown[] = [];
283
+ while (index < parsed.length && parsed[index]?.kind === 'content') {
284
+ run.push(parsed[index]?.op);
285
+ index += 1;
286
+ }
287
+ const theme = registry.theme(envelope.themeId, locale);
288
+ const ctx: AdapterContext = { locale, theme, fail: failIn(locale, index - run.length) };
289
+ try {
290
+ const result = await adapter.apply(draft as never, run as never[], ctx);
291
+ draft = result.content;
292
+ createdRefs.push(...result.createdRefs);
293
+ warnings.push(...result.warnings);
294
+ } catch (error) {
295
+ if (error instanceof ArtifactError) throw error;
296
+ throw new ArtifactError(
297
+ 'OPERATION_FAILED',
298
+ t(locale, 'OPERATION_FAILED', { index: index - run.length }),
299
+ {
317
300
  issues: [
318
301
  {
319
- path: failOptions?.path ? `operations[${index}].${failOptions.path}` : `operations[${index}]`,
320
- message,
321
- ...(failOptions?.hint ? { hint: failOptions.hint } : {}),
302
+ path: `operations[${index - run.length}]`,
303
+ message: t(locale, 'OPERATION_FAILED', { index: index - run.length }),
322
304
  },
323
305
  ],
324
- details: { index },
325
- });
326
- },
327
- };
328
- if (isEnvelopeOperation(op)) {
329
- draft = applyEnvelopeOperation(
330
- registry,
331
- kind,
332
- draft,
333
- envelope,
334
- op as EnvelopeOperation,
335
- index,
336
- allocator,
337
- locale,
306
+ details: { index: index - run.length },
307
+ cause: error,
308
+ },
338
309
  );
339
- return;
340
310
  }
341
- try {
342
- kind.apply(draft, op, ctx);
343
- } catch (error) {
344
- if (error instanceof ArtifactError) throw error;
345
- throw new ArtifactError('OPERATION_FAILED', t(locale, 'OPERATION_FAILED', { index }), {
346
- issues: [{ path: `operations[${index}]`, message: t(locale, 'OPERATION_FAILED', { index }) }],
347
- details: { index },
348
- cause: error,
349
- });
350
- }
351
- });
311
+ }
352
312
 
353
- kind.derive?.(draft);
354
- draft = validateContent(registry, kind, draft, locale);
313
+ const finalTheme = registry.theme(envelope.themeId, locale);
314
+ const finalCtx: AdapterContext = { locale: envelope.locale, theme: finalTheme, fail: failIn(locale, 0) };
315
+ draft = validateContent(registry, adapter, draft, finalCtx);
355
316
  const envelopeBytes = jsonByteSize({ ...envelope, content: draft });
356
317
  if (envelopeBytes > registry.batch.maxEnvelopeBytes) {
357
318
  const message = t(locale, 'LIMIT_EXCEEDED', {
@@ -365,13 +326,11 @@ export function applyOperations(
365
326
  });
366
327
  }
367
328
 
368
- const afterIds = [...kind.ids(draft)];
369
- const theme = registry.theme(envelope.themeId, locale);
370
329
  return {
371
330
  content: draft,
372
- schemaVersion: kind.version,
331
+ formatVersion: adapter.formatVersion,
373
332
  ...envelope,
374
- createdIds: afterIds.filter((id) => !beforeIds.has(id)),
333
+ createdRefs: [...new Set(createdRefs)],
375
334
  diff: [
376
335
  ...diffFields(
377
336
  'envelope',
@@ -380,26 +339,45 @@ export function applyOperations(
380
339
  ['title', 'themeId', 'locale'],
381
340
  locale,
382
341
  ),
383
- ...kind.diff(before, draft, locale),
342
+ ...adapter.diff(before as never, draft as never, locale),
384
343
  ],
385
- lint: kind.lint(draft, { locale: envelope.locale, theme }),
344
+ lint: [...warnings, ...adapter.lint(draft as never, { locale: envelope.locale, theme: finalTheme })],
386
345
  };
387
346
  }
388
347
 
348
+ function replaceContent(
349
+ registry: ArtifactRegistry,
350
+ adapter: ReturnType<ArtifactRegistry['get']>,
351
+ content: unknown,
352
+ locale: string,
353
+ index: number,
354
+ ): unknown {
355
+ const theme = registry.theme('default', locale);
356
+ const result = adapter.validate(content, { locale, theme, fail: failIn(locale, index) });
357
+ if (!result.ok) {
358
+ throw new ArtifactError('INVALID_OPERATION', t(locale, 'INVALID_OPERATION', { index }), {
359
+ issues: result.issues.map((issue) => ({
360
+ ...issue,
361
+ path: `operations[${index}].content.${issue.path}`,
362
+ })),
363
+ details: { index },
364
+ });
365
+ }
366
+ checkLimits(registry, adapter, result.content, locale);
367
+ return result.content;
368
+ }
369
+
389
370
  function applyEnvelopeOperation(
390
371
  registry: ArtifactRegistry,
391
- kind: AnyArtifactKind,
392
- draft: unknown,
393
372
  envelope: { title: string; themeId: string; locale: string },
394
- op: EnvelopeOperation,
373
+ op: Exclude<EnvelopeOperation, { op: 'replace_content' }>,
395
374
  index: number,
396
- allocator: IdAllocator,
397
375
  locale: string,
398
- ): unknown {
376
+ ): void {
399
377
  switch (op.op) {
400
378
  case 'set_title':
401
379
  envelope.title = op.title;
402
- return draft;
380
+ return;
403
381
  case 'set_theme':
404
382
  if (!registry.hasTheme(op.themeId)) {
405
383
  throw new ArtifactError('OPERATION_FAILED', t(locale, 'OPERATION_FAILED', { index }), {
@@ -419,62 +397,37 @@ function applyEnvelopeOperation(
419
397
  });
420
398
  }
421
399
  envelope.themeId = op.themeId;
422
- return draft;
400
+ return;
423
401
  case 'set_locale':
424
402
  envelope.locale = op.locale;
425
- return draft;
426
- case 'replace_content': {
427
- checkDepth(op.content, `operations[${index}].content`, locale);
428
- const raw = sanitizeDeep(op.content);
429
- for (const id of collectExplicitIds(raw)) allocator.reserve(id);
430
- const normalized = kind.normalize
431
- ? kind.normalize(structuredClone(raw), idContext(allocator))
432
- : structuredClone(raw);
433
- const parsed = kind.contentSchema.safeParse(normalized);
434
- if (!parsed.success) {
435
- throw new ArtifactError('INVALID_OPERATION', t(locale, 'INVALID_OPERATION', { index }), {
436
- issues: zodIssuesToErrorIssues(parsed.error.issues, locale, `operations[${index}].content`),
437
- details: { index },
438
- });
439
- }
440
- return parsed.data;
441
- }
403
+ return;
442
404
  }
443
405
  }
444
406
 
445
407
  /**
446
- * Targets an operation batch touches, for automatic rebase (spec §8.3). Uses the kind's `targets` when it
447
- * covers every operation, otherwise the diff produced by applying the batch on its base.
408
+ * Areas a batch touches, for automatic rebase (spec §9.2). Derived from the diff produced by applying the batch on
409
+ * its base, plus the envelope fields it sets. `replace_content` touches everything.
448
410
  */
449
- export function batchTargets(
450
- kind: AnyArtifactKind,
451
- operations: unknown[],
452
- baseDiff: ChangeSummary[],
453
- ): Set<string> {
411
+ export function batchTargets(operations: ParsedOperation[], baseDiff: ChangeSummary[]): Set<string> {
454
412
  const targets = new Set<string>();
455
- const kindOps = operations.filter((op) => !isEnvelopeOperation(op));
456
- for (const op of operations) {
457
- if (!isEnvelopeOperation(op)) continue;
458
- const name = (op as EnvelopeOperation).op;
459
- if (name === 'replace_content') targets.add('*');
460
- else
461
- targets.add(`envelope:${name === 'set_title' ? 'title' : name === 'set_theme' ? 'themeId' : 'locale'}`);
462
- }
463
- if (kind.targets && kindOps.length > 0) {
464
- for (const op of kindOps) for (const target of kind.targets(op)) targets.add(target);
465
- } else {
466
- for (const change of baseDiff) targets.add(change.target);
413
+ for (const item of operations) {
414
+ if (item.kind === 'envelope') {
415
+ const op = item.op;
416
+ if (op.op === 'replace_content') targets.add('*');
417
+ else
418
+ targets.add(
419
+ `envelope:${op.op === 'set_title' ? 'title' : op.op === 'set_theme' ? 'themeId' : 'locale'}`,
420
+ );
421
+ }
467
422
  }
423
+ for (const change of baseDiff) targets.add(change.target);
468
424
  return targets;
469
425
  }
470
426
 
471
427
  /** True when two target sets overlap. `*` overlaps everything. */
472
428
  export function targetsOverlap(a: Set<string>, b: Set<string>): boolean {
473
429
  if ((a.has('*') && b.size > 0) || (b.has('*') && a.size > 0)) return true;
474
- for (const target of a) {
475
- if (b.has(target)) return true;
476
- // A target nested in another (`cell:Resumo!A1` vs `sheet:Resumo`) is handled by kinds emitting both.
477
- }
430
+ for (const target of a) if (b.has(target)) return true;
478
431
  return false;
479
432
  }
480
433
 
package/src/envelope.ts CHANGED
@@ -1,10 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { artifactIdSchema } from './ids.js';
3
3
 
4
- export const ARTIFACT_STATUSES = ['draft', 'pending_approval', 'published', 'archived'] as const;
5
- export type ArtifactStatus = (typeof ARTIFACT_STATUSES)[number];
6
- export const artifactStatusSchema = z.enum(ARTIFACT_STATUSES);
7
-
8
4
  export const actorSchema = z.object({
9
5
  type: z.enum(['agent', 'user', 'system']),
10
6
  id: z.string().min(1).max(128),
@@ -25,19 +21,20 @@ export const metaSchema = z.object({
25
21
  createdBy: actorSchema,
26
22
  updatedBy: actorSchema,
27
23
  tags: z.array(z.string().max(64)).max(32).default([]),
28
- /** Last approved version, kept as the reference while a newer draft waits for approval (spec §15.3). */
29
- publishedVersion: z.number().int().min(1).optional(),
30
24
  });
31
25
  export type ArtifactMeta = z.infer<typeof metaSchema>;
32
26
 
33
- /** Envelope (spec §6.1). Only `content` is produced by the model; everything else is system-owned. */
27
+ /**
28
+ * Envelope. Only `content` is produced by the model; everything else is system-owned. The library keeps the current
29
+ * content and a `version` counter for optimistic concurrency. It keeps no history: saving and versioning belong to the
30
+ * harness, which receives the save action from the editor (see `onSave` in `@artkit-ai/react`).
31
+ */
34
32
  export const envelopeSchema = z.object({
35
33
  id: artifactIdSchema,
36
- kind: z.string().min(1).max(64),
37
- schemaVersion: z.number().int().min(1),
34
+ format: z.string().min(1).max(64),
35
+ formatVersion: z.string().min(1).max(64),
38
36
  version: z.number().int().min(1),
39
37
  title: titleSchema,
40
- status: artifactStatusSchema,
41
38
  locale: localeSchema,
42
39
  themeId: z.string().min(1).max(64),
43
40
  content: z.unknown(),
@@ -46,11 +43,10 @@ export const envelopeSchema = z.object({
46
43
 
47
44
  export interface Artifact<Content = unknown> {
48
45
  id: string;
49
- kind: string;
50
- schemaVersion: number;
46
+ format: string;
47
+ formatVersion: string;
51
48
  version: number;
52
49
  title: string;
53
- status: ArtifactStatus;
54
50
  locale: string;
55
51
  themeId: string;
56
52
  content: Content;
@@ -60,10 +56,9 @@ export interface Artifact<Content = unknown> {
60
56
  /** Lightweight reference carried in chat messages and lists (no content). */
61
57
  export interface ArtifactSummary {
62
58
  id: string;
63
- kind: string;
59
+ format: string;
64
60
  title: string;
65
61
  version: number;
66
- status: ArtifactStatus;
67
62
  locale: string;
68
63
  updatedAt: string;
69
64
  }
@@ -71,23 +66,10 @@ export interface ArtifactSummary {
71
66
  export function summarize(artifact: Artifact): ArtifactSummary {
72
67
  return {
73
68
  id: artifact.id,
74
- kind: artifact.kind,
69
+ format: artifact.format,
75
70
  title: artifact.title,
76
71
  version: artifact.version,
77
- status: artifact.status,
78
72
  locale: artifact.locale,
79
73
  updatedAt: artifact.meta.updatedAt,
80
74
  };
81
75
  }
82
-
83
- /** Allowed lifecycle transitions (spec §15.3). */
84
- export const STATUS_TRANSITIONS: Record<ArtifactStatus, readonly ArtifactStatus[]> = {
85
- draft: ['pending_approval', 'published', 'archived'],
86
- pending_approval: ['published', 'draft', 'archived'],
87
- published: ['archived', 'draft'],
88
- archived: ['draft'],
89
- };
90
-
91
- export function canTransition(from: ArtifactStatus, to: ArtifactStatus): boolean {
92
- return STATUS_TRANSITIONS[from].includes(to);
93
- }