@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/service.ts CHANGED
@@ -1,79 +1,26 @@
1
+ import type { AnyFormatAdapter, ChangeSummary, LintIssue } from './adapter.js';
1
2
  import type { AssetRef, AssetResolver } from './assets.js';
2
- import { deepEqual } from './diff.js';
3
- import {
4
- applyOperations,
5
- type BatchResult,
6
- batchTargets,
7
- envelopeOperationSchema,
8
- parseOperations,
9
- prepareContent,
10
- targetsOverlap,
11
- } from './engine.js';
12
- import {
13
- type Artifact,
14
- type ArtifactStatus,
15
- type ArtifactSummary,
16
- canTransition,
17
- summarize,
18
- titleSchema,
19
- } from './envelope.js';
3
+ import { applyOperations, type BatchResult, prepareContent } from './engine.js';
4
+ import { type Artifact, type ArtifactSummary, summarize, titleSchema } from './envelope.js';
20
5
  import { ArtifactError, zodIssuesToErrorIssues } from './errors.js';
21
6
  import { createEventBus, type EventBus } from './events.js';
22
7
  import { t } from './i18n.js';
23
- import type { ChangeSummary, LintIssue, Projection, ProjectionOptions } from './kind.js';
24
8
  import { type LogFields, type Logger, type MetricsHooks, silentLogger } from './logger.js';
25
9
  import { migrateContent } from './migrations.js';
26
- import { delimitProjection } from './projection.js';
10
+ import { delimitProjection, type Projection, type ProjectionOptions } from './projection.js';
27
11
  import type { ArtifactRegistry } from './registry.js';
28
12
  import { sanitizeDeep } from './sanitize.js';
29
- import type {
30
- ArtifactFilter,
31
- ArtifactStore,
32
- AssetStore,
33
- Lock,
34
- Page,
35
- PageRequest,
36
- Scope,
37
- VersionEntry,
38
- } from './store.js';
39
-
40
- export type ArtifactAction =
41
- | 'create'
42
- | 'read'
43
- | 'apply'
44
- | 'export'
45
- | 'list'
46
- | 'history'
47
- | 'submit'
48
- | 'approve'
49
- | 'reject'
50
- | 'publish'
51
- | 'archive'
52
- | 'restore'
53
- | 'lock';
54
-
55
- /** Mandatory authorization hook in toolkits (spec §16). Return false to deny. */
13
+ import type { ArtifactFilter, ArtifactStore, AssetStore, Lock, Page, PageRequest, Scope } from './store.js';
14
+
15
+ export type ArtifactAction = 'create' | 'read' | 'apply' | 'export' | 'list' | 'lock';
16
+
17
+ /** Mandatory authorization hook in toolkits (spec §16 and §15.1). Return false to deny. */
56
18
  export type AuthorizeFn = (
57
19
  action: ArtifactAction,
58
20
  artifact: ArtifactSummary | null,
59
21
  scope: Scope,
60
22
  ) => boolean | Promise<boolean>;
61
23
 
62
- export interface ApprovalPolicy {
63
- /** Publishing goes through submit → approve (default true). */
64
- requireApprovalToPublish: boolean;
65
- /** Only published artifacts can be exported (default false). */
66
- requireApprovalToExport: boolean;
67
- /** Agents cannot edit published artifacts without approval (default false). Edits create a new draft. */
68
- requireApprovalToEditPublished: boolean;
69
- }
70
-
71
- export const DEFAULT_APPROVAL_POLICY: ApprovalPolicy = {
72
- requireApprovalToPublish: true,
73
- requireApprovalToExport: false,
74
- requireApprovalToEditPublished: false,
75
- };
76
-
77
24
  export interface ArtifactServiceOptions {
78
25
  registry: ArtifactRegistry;
79
26
  store: ArtifactStore;
@@ -87,47 +34,38 @@ export interface ArtifactServiceOptions {
87
34
  /** Default locale of new artifacts. */
88
35
  defaultArtifactLocale?: string;
89
36
  defaultThemeId?: string;
90
- policy?: Partial<ApprovalPolicy>;
91
- /** Times a commit is retried when another writer wins the race (default 3). */
92
- commitRetries?: number;
93
37
  /** Default export timeout (default 60s). */
94
38
  exportTimeoutMs?: number;
95
39
  }
96
40
 
97
41
  export interface CreateRequest {
98
- kind: string;
42
+ format: string;
99
43
  title: string;
44
+ /** Native content: Markdown text, OpenUI Lang text or an `IWorkbookData` object (spec §15.1). */
100
45
  content?: unknown;
101
- /** Alternative input, e.g. `{ format: 'markdown', source: '# Title' }`. */
102
- input?: { format: string; source: unknown };
46
+ /** Initial batch applied on the adapter's empty content, e.g. the first operations of a sheet. */
47
+ operations?: unknown[];
103
48
  locale?: string;
104
49
  themeId?: string;
105
50
  tags?: string[];
106
- message?: string;
107
- idempotencyKey?: string;
108
51
  }
109
52
 
110
53
  export interface CommitResult {
111
54
  artifact: Artifact;
112
55
  version: number;
113
- createdIds: string[];
56
+ createdRefs: string[];
114
57
  diff: ChangeSummary[];
115
58
  lint: LintIssue[];
116
- /** True when the batch was applied on a newer version than its baseVersion. */
117
- rebased: boolean;
118
- /** True when an earlier call with the same idempotency key was replayed. */
119
- replayed: boolean;
120
59
  }
121
60
 
122
61
  export interface ApplyRequest {
123
62
  artifactId: string;
63
+ /** Version the operations were computed against. Anything else than the current version is a VERSION_CONFLICT. */
124
64
  baseVersion: number;
125
65
  operations: unknown[];
126
- idempotencyKey?: string;
127
- message?: string;
128
66
  }
129
67
 
130
- export interface ReadResult {
68
+ export interface ArtifactReadResult {
131
69
  artifact: ArtifactSummary;
132
70
  version: number;
133
71
  projection: Projection;
@@ -137,8 +75,8 @@ export interface ReadResult {
137
75
 
138
76
  export interface ExportRequest {
139
77
  artifactId: string;
78
+ /** Target file format: `docx`, `xlsx`, `pptx`, `pdf`, `png`, `html`, `md`, `csv`… */
140
79
  format: string;
141
- version?: number;
142
80
  signal?: AbortSignal;
143
81
  timeoutMs?: number;
144
82
  /** Store the result in the AssetStore and return its reference. */
@@ -159,23 +97,12 @@ export interface ArtifactService {
159
97
  readonly registry: ArtifactRegistry;
160
98
  readonly events: EventBus;
161
99
  create(scope: Scope, request: CreateRequest): Promise<CommitResult>;
162
- get(scope: Scope, id: string, opts?: { version?: number }): Promise<Artifact>;
163
- read(
164
- scope: Scope,
165
- id: string,
166
- options?: Partial<ProjectionOptions> & { version?: number },
167
- ): Promise<ReadResult>;
100
+ get(scope: Scope, id: string): Promise<Artifact>;
101
+ read(scope: Scope, id: string, options?: Partial<ProjectionOptions>): Promise<ArtifactReadResult>;
168
102
  apply(scope: Scope, request: ApplyRequest): Promise<CommitResult>;
169
103
  /** Pure dry run on the current version: validates and returns diff/lint without committing. */
170
104
  preview(scope: Scope, request: ApplyRequest): Promise<BatchResult>;
171
105
  list(scope: Scope, filter?: ArtifactFilter, page?: PageRequest): Promise<Page<ArtifactSummary>>;
172
- history(scope: Scope, id: string, page?: PageRequest): Promise<Page<VersionEntry>>;
173
- restore(scope: Scope, id: string, version: number, message?: string): Promise<CommitResult>;
174
- submit(scope: Scope, id: string, message?: string): Promise<Artifact>;
175
- approve(scope: Scope, id: string, message?: string): Promise<Artifact>;
176
- reject(scope: Scope, id: string, reason?: string): Promise<Artifact>;
177
- publish(scope: Scope, id: string): Promise<Artifact>;
178
- archive(scope: Scope, id: string): Promise<Artifact>;
179
106
  lock(scope: Scope, id: string, ttlMs?: number): Promise<Lock>;
180
107
  unlock(scope: Scope, lock: Lock): Promise<void>;
181
108
  /** Current lock holder, or null; lets UIs show who is editing before trying to write. */
@@ -186,8 +113,8 @@ export interface ArtifactService {
186
113
  }
187
114
 
188
115
  /**
189
- * The high-level pipeline (spec §8.2): authorization, migration, baseVersion check with automatic rebase,
190
- * atomic apply, optimistic commit with retries, idempotency, events and metadata-only logging.
116
+ * The core pipeline (spec §9.1): authorization, migration, baseVersion check, atomic apply, optimistic commit, events
117
+ * and metadata-only logging. There is no history, approval or archive here: the harness decides what a save means.
191
118
  */
192
119
  export function createArtifactService(options: ArtifactServiceOptions): ArtifactService {
193
120
  const { registry, store } = options;
@@ -197,8 +124,6 @@ export function createArtifactService(options: ArtifactServiceOptions): Artifact
197
124
  const events =
198
125
  options.events ??
199
126
  createEventBus((error) => logger.error('event handler failed', { code: String(error) }));
200
- const policy: ApprovalPolicy = { ...DEFAULT_APPROVAL_POLICY, ...options.policy };
201
- const retries = options.commitRetries ?? 3;
202
127
 
203
128
  async function authorize(
204
129
  action: ArtifactAction,
@@ -213,42 +138,38 @@ export function createArtifactService(options: ArtifactServiceOptions): Artifact
213
138
  }
214
139
  }
215
140
 
216
- async function load(scope: Scope, id: string, version?: number): Promise<Artifact> {
217
- const found = await store.get(scope, id, version === undefined ? undefined : { version });
141
+ async function load(scope: Scope, id: string): Promise<Artifact> {
142
+ const found = await store.get(scope, id);
218
143
  if (!found) {
219
- throw new ArtifactError(
220
- 'NOT_FOUND',
221
- t(locale, 'NOT_FOUND', { what: version ? `Version ${version}` : 'Artifact', id }),
222
- {
223
- issues: [
224
- {
225
- path: 'id',
226
- message: t(locale, 'NOT_FOUND', { what: 'Artifact', id }),
227
- hint: t(locale, 'NOT_FOUND_HINT'),
228
- },
229
- ],
230
- },
231
- );
144
+ throw new ArtifactError('NOT_FOUND', t(locale, 'NOT_FOUND', { what: 'Artifact', id }), {
145
+ issues: [
146
+ {
147
+ path: 'id',
148
+ message: t(locale, 'NOT_FOUND', { what: 'Artifact', id }),
149
+ hint: t(locale, 'NOT_FOUND_HINT'),
150
+ },
151
+ ],
152
+ });
232
153
  }
233
154
  return migrated(found);
234
155
  }
235
156
 
236
157
  function migrated(artifact: Artifact): Artifact {
237
- const kind = registry.get(artifact.kind, locale);
238
- const result = migrateContent(kind, artifact.content, artifact.schemaVersion, locale);
158
+ const adapter = registry.get(artifact.format, locale);
159
+ const result = migrateContent(adapter, artifact.content, artifact.formatVersion, locale);
239
160
  return result.migrated
240
- ? { ...artifact, content: result.content, schemaVersion: result.schemaVersion }
161
+ ? { ...artifact, content: result.content, formatVersion: result.formatVersion }
241
162
  : artifact;
242
163
  }
243
164
 
244
165
  function fields(
245
- artifact: Pick<Artifact, 'id' | 'kind' | 'version'>,
166
+ artifact: Pick<Artifact, 'id' | 'format' | 'version'>,
246
167
  scope: Scope,
247
168
  extra: LogFields = {},
248
169
  ): LogFields {
249
170
  return {
250
171
  artifactId: artifact.id,
251
- kind: artifact.kind,
172
+ format: artifact.format,
252
173
  version: artifact.version,
253
174
  actorType: scope.actor.type,
254
175
  ...extra,
@@ -275,147 +196,28 @@ export function createArtifactService(options: ArtifactServiceOptions): Artifact
275
196
  );
276
197
  }
277
198
 
278
- async function replay(
279
- scope: Scope,
280
- id: string,
281
- key: string | undefined,
282
- ): Promise<CommitResult | undefined> {
283
- if (!key) return undefined;
284
- const entry = await store.findByIdempotencyKey(scope, id, key);
285
- if (!entry) return undefined;
286
- const artifact = await load(scope, id, entry.version);
287
- const kind = registry.get(artifact.kind, locale);
288
- return {
289
- artifact,
290
- version: entry.version,
291
- createdIds: entry.createdIds,
292
- diff: entry.diff,
293
- lint: kind.lint(artifact.content, {
294
- locale: artifact.locale,
295
- theme: registry.theme(artifact.themeId, locale),
296
- }),
297
- rebased: false,
298
- replayed: true,
299
- };
300
- }
301
-
302
- /** Applies on the current version, rebasing from an older base when the touched items did not change. */
303
- async function computeBatch(
304
- scope: Scope,
305
- current: Artifact,
306
- request: ApplyRequest,
307
- ): Promise<{ result: BatchResult; rebased: boolean }> {
308
- if (request.baseVersion === current.version) {
309
- return { result: applyOperations(registry, current, request.operations, { locale }), rebased: false };
310
- }
311
- const conflict = (changes: ChangeSummary[]) => {
312
- metrics.conflict?.(fields(current, scope));
313
- events.emit({
314
- type: 'artifact.conflict',
315
- artifactId: current.id,
316
- baseVersion: request.baseVersion,
317
- currentVersion: current.version,
318
- actor: scope.actor,
319
- });
320
- return new ArtifactError(
321
- 'VERSION_CONFLICT',
322
- t(locale, 'VERSION_CONFLICT', { baseVersion: request.baseVersion, currentVersion: current.version }),
323
- {
324
- issues: [
325
- {
326
- path: 'baseVersion',
327
- message: t(locale, 'VERSION_CONFLICT', {
328
- baseVersion: request.baseVersion,
329
- currentVersion: current.version,
330
- }),
331
- hint: t(locale, 'VERSION_CONFLICT_HINT'),
332
- },
333
- ],
334
- details: { currentVersion: current.version, changesSinceBase: changes },
335
- },
336
- );
337
- };
338
- if (request.baseVersion > current.version || request.baseVersion < 1) throw conflict([]);
339
- const base = await load(scope, current.id, request.baseVersion);
340
- const kind = registry.get(current.kind, locale);
341
- // Validate against the base first: an invalid batch is the model's error, not a conflict.
342
- const onBase = applyOperations(registry, base, request.operations, { locale });
343
- const changedSince = [
344
- ...(base.title !== current.title
345
- ? [
346
- {
347
- type: 'changed' as const,
348
- target: 'envelope:title',
349
- summary: `'${base.title}' → '${current.title}'`,
350
- },
351
- ]
352
- : []),
353
- ...(base.themeId !== current.themeId
354
- ? [
355
- {
356
- type: 'changed' as const,
357
- target: 'envelope:themeId',
358
- summary: `${base.themeId} → ${current.themeId}`,
359
- },
360
- ]
361
- : []),
362
- ...(base.locale !== current.locale
363
- ? [
364
- {
365
- type: 'changed' as const,
366
- target: 'envelope:locale',
367
- summary: `${base.locale} → ${current.locale}`,
368
- },
369
- ]
370
- : []),
371
- ...kind.diff(base.content, current.content, locale),
372
- ];
373
- const parsed = parseOperations(registry, current.kind, request.operations, locale);
374
- const touched = batchTargets(kind, parsed, onBase.diff);
375
- const changed = new Set(changedSince.map((change) => change.target));
376
- if (targetsOverlap(touched, changed)) throw conflict(changedSince);
377
- try {
378
- return { result: applyOperations(registry, current, request.operations, { locale }), rebased: true };
379
- } catch (error) {
380
- if (
381
- error instanceof ArtifactError &&
382
- (error.code === 'OPERATION_FAILED' || error.code === 'INVALID_CONTENT')
383
- ) {
384
- throw conflict(changedSince);
385
- }
386
- throw error;
387
- }
388
- }
389
-
390
- async function changeStatus(
391
- scope: Scope,
392
- id: string,
393
- to: ArtifactStatus,
394
- action: ArtifactAction,
395
- message?: string,
396
- ): Promise<Artifact> {
397
- const current = await load(scope, id);
398
- await authorize(action, summarize(current), scope);
399
- if (!canTransition(current.status, to)) {
400
- throw new ArtifactError(
401
- 'PERMISSION_DENIED',
402
- t(locale, 'INVALID_STATUS_TRANSITION', { from: current.status, to }),
403
- {
404
- details: { from: current.status, to },
405
- },
406
- );
407
- }
408
- const updated = await store.setStatus(scope, id, to, scope.actor, message);
199
+ /** The batch must be computed against the current version; a stale base is a conflict, never a silent rebase. */
200
+ function assertBase(scope: Scope, current: Artifact, baseVersion: number): void {
201
+ if (baseVersion === current.version) return;
202
+ metrics.conflict?.(fields(current, scope));
409
203
  events.emit({
410
- type: 'artifact.status_changed',
411
- artifact: summarize(updated),
412
- from: current.status,
413
- to,
204
+ type: 'artifact.conflict',
205
+ artifactId: current.id,
206
+ baseVersion,
207
+ currentVersion: current.version,
414
208
  actor: scope.actor,
415
- ...(message ? { message } : {}),
416
209
  });
417
- logger.info('artifact.status_changed', fields(updated, scope, { from: current.status, to }));
418
- return updated;
210
+ const message = t(locale, 'VERSION_CONFLICT', { baseVersion, currentVersion: current.version });
211
+ throw new ArtifactError('VERSION_CONFLICT', message, {
212
+ issues: [
213
+ {
214
+ path: 'baseVersion',
215
+ message,
216
+ hint: t(locale, 'VERSION_CONFLICT_HINT'),
217
+ },
218
+ ],
219
+ details: { currentVersion: current.version },
220
+ });
419
221
  }
420
222
 
421
223
  const service: ArtifactService = {
@@ -424,28 +226,7 @@ export function createArtifactService(options: ArtifactServiceOptions): Artifact
424
226
 
425
227
  async create(scope, request) {
426
228
  await authorize('create', null, scope);
427
- const kind = registry.get(request.kind, locale);
428
- if (request.idempotencyKey && store.findCreatedByIdempotencyKey) {
429
- const existing = await store.findCreatedByIdempotencyKey(scope, request.idempotencyKey);
430
- if (existing) {
431
- const first = await load(scope, existing.id, 1);
432
- const prepared = [...registry.get(first.kind, locale).ids(first.content)];
433
- return {
434
- artifact: first,
435
- version: 1,
436
- createdIds: prepared,
437
- diff: [
438
- { type: 'added', target: `artifact:${first.id}`, summary: `${kind.label} "${first.title}"` },
439
- ],
440
- lint: kind.lint(first.content, {
441
- locale: first.locale,
442
- theme: registry.theme(first.themeId, locale),
443
- }),
444
- rebased: false,
445
- replayed: true,
446
- };
447
- }
448
- }
229
+ const adapter = registry.get(request.format, locale);
449
230
  const title = titleSchema.safeParse(sanitizeDeep(request.title));
450
231
  if (!title.success) {
451
232
  throw new ArtifactError('INVALID_CONTENT', t(locale, 'INVALID_CONTENT'), {
@@ -455,318 +236,152 @@ export function createArtifactService(options: ArtifactServiceOptions): Artifact
455
236
  const artifactLocale = request.locale ?? options.defaultArtifactLocale ?? 'en';
456
237
  const themeId = request.themeId ?? options.defaultThemeId ?? 'default';
457
238
  registry.theme(themeId, locale);
458
- let raw: unknown;
459
- const inputNotes: string[] = [];
460
- if (request.input) {
461
- const input = kind.inputs?.[request.input.format];
462
- if (!input) {
463
- const available = Object.keys(kind.inputs ?? {});
464
- throw new ArtifactError(
465
- 'INVALID_CONTENT',
466
- t(locale, 'INPUT_FORMAT_UNSUPPORTED', { kind: kind.kind, format: request.input.format }),
467
- {
468
- issues: [
469
- {
470
- path: 'input.format',
471
- message: t(locale, 'INPUT_FORMAT_UNSUPPORTED', {
472
- kind: kind.kind,
473
- format: request.input.format,
474
- }),
475
- hint: t(locale, 'INPUT_FORMAT_HINT', { available: available.join(', ') || '—' }),
476
- },
477
- ],
478
- },
479
- );
480
- }
481
- const parsedInput = input.schema.safeParse(sanitizeDeep(request.input.source));
482
- if (!parsedInput.success) {
483
- throw new ArtifactError('INVALID_CONTENT', t(locale, 'INVALID_CONTENT'), {
484
- issues: zodIssuesToErrorIssues(parsedInput.error.issues, locale, 'input.source'),
485
- });
486
- }
487
- raw = input.toContent(parsedInput.data, {
488
- locale: artifactLocale,
489
- note: (message) => inputNotes.push(message),
490
- });
491
- } else {
492
- raw = request.content ?? kind.empty();
493
- }
494
- const prepared = prepareContent(registry, kind.kind, raw, { locale, theme: themeId });
495
- prepared.lint.push(
496
- ...inputNotes.map((message) => ({
497
- code: 'input-note',
498
- severity: 'info' as const,
499
- target: `input:${request.input?.format ?? 'content'}`,
500
- message,
501
- })),
502
- );
239
+ const base = {
240
+ id: '',
241
+ format: adapter.format,
242
+ formatVersion: adapter.formatVersion,
243
+ version: 1,
244
+ title: title.data,
245
+ locale: artifactLocale,
246
+ themeId,
247
+ content: request.content ?? adapter.empty(),
248
+ };
249
+ // Creation with an initial batch goes through the same pipeline as any edit.
250
+ const applied = request.operations?.length
251
+ ? await applyOperations(registry, base as Artifact, request.operations, { locale })
252
+ : undefined;
253
+ const prepared = applied
254
+ ? { content: applied.content, lint: applied.lint }
255
+ : prepareContent(registry, request.format, base.content, { locale: artifactLocale, themeId });
503
256
  const artifact = await store.create(scope, {
504
- kind: kind.kind,
505
- schemaVersion: kind.version,
257
+ format: adapter.format,
258
+ formatVersion: adapter.formatVersion,
506
259
  title: title.data,
507
- status: 'draft',
508
260
  locale: artifactLocale,
509
261
  themeId,
510
262
  content: prepared.content,
511
- createdIds: prepared.ids,
512
263
  ...(request.tags ? { tags: request.tags } : {}),
513
- ...(request.message ? { message: request.message } : {}),
514
- ...(request.idempotencyKey ? { idempotencyKey: request.idempotencyKey } : {}),
515
264
  });
516
265
  events.emit({ type: 'artifact.created', artifact: summarize(artifact), actor: scope.actor });
517
- if (prepared.lint.length > 0)
266
+ if (prepared.lint.length > 0) {
518
267
  events.emit({
519
268
  type: 'artifact.lint',
520
269
  artifactId: artifact.id,
521
270
  version: artifact.version,
522
271
  warnings: prepared.lint.length,
523
272
  });
273
+ }
524
274
  logger.info('artifact.created', fields(artifact, scope));
525
275
  return {
526
276
  artifact,
527
277
  version: artifact.version,
528
- createdIds: prepared.ids,
278
+ createdRefs: applied?.createdRefs ?? [],
529
279
  diff: [
530
- { type: 'added', target: `artifact:${artifact.id}`, summary: `${kind.label} "${artifact.title}"` },
280
+ {
281
+ type: 'added',
282
+ target: `artifact:${artifact.id}`,
283
+ summary: `${adapter.label} "${artifact.title}"`,
284
+ },
531
285
  ],
532
286
  lint: prepared.lint,
533
- rebased: false,
534
- replayed: false,
535
287
  };
536
288
  },
537
289
 
538
- async get(scope, id, opts) {
539
- const artifact = await load(scope, id, opts?.version);
290
+ async get(scope, id) {
291
+ const artifact = await load(scope, id);
540
292
  await authorize('read', summarize(artifact), scope);
541
293
  return artifact;
542
294
  },
543
295
 
544
296
  async read(scope, id, opts = {}) {
545
- const artifact = await load(scope, id, opts.version);
297
+ const artifact = await load(scope, id);
546
298
  await authorize('read', summarize(artifact), scope);
547
- const kind = registry.get(artifact.kind, locale);
548
- const projection = kind.project(artifact.content, {
299
+ const adapter = registry.get(artifact.format, locale);
300
+ const result = adapter.read(artifact.content as never, {
549
301
  mode: opts.mode ?? 'outline',
550
302
  locale: artifact.locale,
551
- theme: registry.theme(artifact.themeId, locale),
552
303
  ...(opts.range === undefined ? {} : { range: opts.range }),
553
304
  ...(opts.maxTokens === undefined ? {} : { maxTokens: opts.maxTokens }),
554
305
  ...(opts.cursor === undefined ? {} : { cursor: opts.cursor }),
555
306
  ...(opts.charsPerToken === undefined ? {} : { charsPerToken: opts.charsPerToken }),
556
307
  });
557
- const header = `${kind.label} "${artifact.title}" (id: ${artifact.id}, kind: ${artifact.kind}, version: ${artifact.version}, status: ${artifact.status}, locale: ${artifact.locale})`;
308
+ const projection: Projection = {
309
+ text: result.text,
310
+ truncated: result.truncated,
311
+ ...(result.cursor === undefined ? {} : { cursor: result.cursor }),
312
+ };
313
+ const header = `${adapter.label} "${artifact.title}" (id: ${artifact.id}, format: ${artifact.format}, version: ${artifact.version}, locale: ${artifact.locale})`;
558
314
  let text = `${header}\n${delimitProjection(artifact.id, projection.text)}`;
559
315
  if (projection.truncated) {
560
- text += `\n[truncated — continue with cursor "${projection.cursor}"]\nRemaining outline:\n${projection.remainingOutline ?? ''}`;
316
+ text += `\n[truncated — continue with cursor "${projection.cursor}"]`;
561
317
  }
562
318
  return { artifact: summarize(artifact), version: artifact.version, projection, text };
563
319
  },
564
320
 
565
321
  async apply(scope, request) {
566
322
  const started = Date.now();
567
- let current = await load(scope, request.artifactId);
323
+ const current = await load(scope, request.artifactId);
568
324
  await authorize('apply', summarize(current), scope);
569
- const replayed = await replay(scope, current.id, request.idempotencyKey);
570
- if (replayed) return replayed;
571
325
  await assertNotLocked(scope, current.id);
572
- if (
573
- current.status === 'published' &&
574
- policy.requireApprovalToEditPublished &&
575
- scope.actor.type === 'agent'
576
- ) {
577
- throw new ArtifactError('APPROVAL_REQUIRED', t(locale, 'APPROVAL_REQUIRED', { action: 'apply' }), {
578
- issues: [
579
- {
580
- path: 'artifactId',
581
- message: t(locale, 'APPROVAL_REQUIRED', { action: 'apply' }),
582
- hint: t(locale, 'APPROVAL_REQUIRED_HINT'),
583
- },
584
- ],
326
+ assertBase(scope, current, request.baseVersion);
327
+ const result = await applyOperations(registry, current, request.operations, { locale });
328
+ let committed: Artifact;
329
+ try {
330
+ committed = await store.commit(scope, current.id, {
331
+ baseVersion: current.version,
332
+ formatVersion: result.formatVersion,
333
+ content: result.content,
334
+ title: result.title,
335
+ locale: result.locale,
336
+ themeId: result.themeId,
585
337
  });
338
+ } catch (error) {
339
+ if (error instanceof ArtifactError) metrics.rejection?.(error.code, fields(current, scope));
340
+ throw error;
586
341
  }
587
- for (let attempt = 0; ; attempt++) {
588
- const { result, rebased } = await computeBatch(scope, current, request);
589
- try {
590
- const committed = await store.commit(scope, current.id, {
591
- baseVersion: current.version,
592
- schemaVersion: result.schemaVersion,
593
- content: result.content,
594
- title: result.title,
595
- locale: result.locale,
596
- themeId: result.themeId,
597
- // Editing a published (or pending) artifact starts a new draft (spec §15.3).
598
- ...(current.status === 'published' || current.status === 'pending_approval'
599
- ? { status: 'draft' as const }
600
- : {}),
601
- operations: request.operations,
602
- diff: result.diff,
603
- createdIds: result.createdIds,
604
- ...(request.message ? { message: request.message } : {}),
605
- ...(request.idempotencyKey ? { idempotencyKey: request.idempotencyKey } : {}),
606
- });
607
- const duration = Date.now() - started;
608
- const logFields = fields(committed, scope, {
609
- operationCount: request.operations.length,
610
- durationMs: duration,
611
- });
612
- metrics.applyDuration?.(duration, logFields);
613
- logger.info('artifact.committed', logFields);
614
- events.emit({
615
- type: 'artifact.committed',
616
- artifact: summarize(committed),
617
- actor: scope.actor,
618
- operationCount: request.operations.length,
619
- diff: result.diff,
620
- rebased,
621
- });
622
- if (result.lint.length > 0)
623
- events.emit({
624
- type: 'artifact.lint',
625
- artifactId: committed.id,
626
- version: committed.version,
627
- warnings: result.lint.length,
628
- });
629
- return {
630
- artifact: committed,
631
- version: committed.version,
632
- createdIds: result.createdIds,
633
- diff: result.diff,
634
- lint: result.lint,
635
- rebased,
636
- replayed: false,
637
- };
638
- } catch (error) {
639
- // Another writer won the race: reload and rebase from the original base.
640
- if (error instanceof ArtifactError && error.code === 'VERSION_CONFLICT' && attempt < retries) {
641
- current = await load(scope, request.artifactId);
642
- continue;
643
- }
644
- if (error instanceof ArtifactError) metrics.rejection?.(error.code, fields(current, scope));
645
- throw error;
646
- }
647
- }
648
- },
649
-
650
- async preview(scope, request) {
651
- const current = await load(scope, request.artifactId);
652
- await authorize('read', summarize(current), scope);
653
- return (await computeBatch(scope, current, request)).result;
654
- },
655
-
656
- async list(scope, filter = {}, page = {}) {
657
- await authorize('list', null, scope);
658
- return store.list(scope, filter, page);
659
- },
660
-
661
- async history(scope, id, page = {}) {
662
- const current = await load(scope, id);
663
- await authorize('history', summarize(current), scope);
664
- return store.history(scope, id, page);
665
- },
666
-
667
- async restore(scope, id, version, message) {
668
- const current = await load(scope, id);
669
- await authorize('restore', summarize(current), scope);
670
- await assertNotLocked(scope, id);
671
- const old = await load(scope, id, version);
672
- const kind = registry.get(current.kind, locale);
673
- const diff = [
674
- ...(old.title !== current.title
675
- ? [
676
- {
677
- type: 'changed' as const,
678
- target: 'envelope:title',
679
- summary: `'${current.title}' → '${old.title}'`,
680
- },
681
- ]
682
- : []),
683
- ...kind.diff(current.content, old.content, locale),
684
- ];
685
- const beforeIds = new Set<string>(kind.ids(current.content));
686
- const createdIds = [...kind.ids(old.content)].filter((itemId) => !beforeIds.has(itemId));
687
- const committed = await store.commit(scope, id, {
688
- baseVersion: current.version,
689
- schemaVersion: old.schemaVersion,
690
- content: old.content,
691
- title: old.title,
692
- locale: old.locale,
693
- themeId: old.themeId,
694
- ...(current.status !== 'draft' && current.status !== 'archived' ? { status: 'draft' as const } : {}),
695
- operations: [{ op: 'replace_content', content: '[restored]' }],
696
- diff,
697
- createdIds,
698
- restoredFrom: version,
699
- message: message ?? `Restored version ${version}`,
342
+ const duration = Date.now() - started;
343
+ const logFields = fields(committed, scope, {
344
+ operationCount: request.operations.length,
345
+ durationMs: duration,
700
346
  });
347
+ metrics.applyDuration?.(duration, logFields);
348
+ logger.info('artifact.committed', logFields);
701
349
  events.emit({
702
350
  type: 'artifact.committed',
703
351
  artifact: summarize(committed),
704
352
  actor: scope.actor,
705
- operationCount: 0,
706
- diff,
707
- rebased: false,
353
+ operationCount: request.operations.length,
354
+ diff: result.diff,
708
355
  });
356
+ if (result.lint.length > 0) {
357
+ events.emit({
358
+ type: 'artifact.lint',
359
+ artifactId: committed.id,
360
+ version: committed.version,
361
+ warnings: result.lint.length,
362
+ });
363
+ }
709
364
  return {
710
365
  artifact: committed,
711
366
  version: committed.version,
712
- createdIds,
713
- diff,
714
- lint: kind.lint(committed.content, {
715
- locale: committed.locale,
716
- theme: registry.theme(committed.themeId, locale),
717
- }),
718
- rebased: false,
719
- replayed: false,
367
+ createdRefs: result.createdRefs,
368
+ diff: result.diff,
369
+ lint: result.lint,
720
370
  };
721
371
  },
722
372
 
723
- submit: (scope, id, message) => changeStatus(scope, id, 'pending_approval', 'submit', message),
724
-
725
- async approve(scope, id, message) {
726
- if (scope.actor.type === 'agent') {
727
- throw new ArtifactError('PERMISSION_DENIED', t(locale, 'PERMISSION_DENIED', { action: 'approve' }));
728
- }
729
- const current = await load(scope, id);
730
- if (current.status !== 'pending_approval') {
731
- throw new ArtifactError(
732
- 'PERMISSION_DENIED',
733
- t(locale, 'INVALID_STATUS_TRANSITION', { from: current.status, to: 'published' }),
734
- );
735
- }
736
- return changeStatus(scope, id, 'published', 'approve', message);
737
- },
738
-
739
- async reject(scope, id, reason) {
740
- if (scope.actor.type === 'agent') {
741
- throw new ArtifactError('PERMISSION_DENIED', t(locale, 'PERMISSION_DENIED', { action: 'reject' }));
742
- }
743
- const current = await load(scope, id);
744
- if (current.status !== 'pending_approval') {
745
- throw new ArtifactError(
746
- 'PERMISSION_DENIED',
747
- t(locale, 'INVALID_STATUS_TRANSITION', { from: current.status, to: 'draft' }),
748
- );
749
- }
750
- return changeStatus(scope, id, 'draft', 'reject', reason);
373
+ async preview(scope, request) {
374
+ const current = await load(scope, request.artifactId);
375
+ await authorize('read', summarize(current), scope);
376
+ assertBase(scope, current, request.baseVersion);
377
+ return applyOperations(registry, current, request.operations, { locale });
751
378
  },
752
379
 
753
- async publish(scope, id) {
754
- if (policy.requireApprovalToPublish) {
755
- throw new ArtifactError('APPROVAL_REQUIRED', t(locale, 'APPROVAL_REQUIRED', { action: 'publish' }), {
756
- issues: [
757
- {
758
- path: 'id',
759
- message: t(locale, 'APPROVAL_REQUIRED', { action: 'publish' }),
760
- hint: t(locale, 'APPROVAL_REQUIRED_HINT'),
761
- },
762
- ],
763
- });
764
- }
765
- return changeStatus(scope, id, 'published', 'publish');
380
+ async list(scope, filter = {}, page = {}) {
381
+ await authorize('list', null, scope);
382
+ return store.list(scope, filter, page);
766
383
  },
767
384
 
768
- archive: (scope, id) => changeStatus(scope, id, 'archived', 'archive'),
769
-
770
385
  async lock(scope, id, ttlMs = 5 * 60_000) {
771
386
  const current = await load(scope, id);
772
387
  await authorize('lock', summarize(current), scope);
@@ -783,20 +398,9 @@ export function createArtifactService(options: ArtifactServiceOptions): Artifact
783
398
 
784
399
  async export(scope, request) {
785
400
  const started = Date.now();
786
- const artifact = await load(scope, request.artifactId, request.version);
401
+ const artifact = await load(scope, request.artifactId);
787
402
  await authorize('export', summarize(artifact), scope);
788
- if (policy.requireApprovalToExport && artifact.status !== 'published') {
789
- throw new ArtifactError('APPROVAL_REQUIRED', t(locale, 'APPROVAL_REQUIRED', { action: 'export' }), {
790
- issues: [
791
- {
792
- path: 'artifactId',
793
- message: t(locale, 'APPROVAL_REQUIRED', { action: 'export' }),
794
- hint: t(locale, 'APPROVAL_REQUIRED_HINT'),
795
- },
796
- ],
797
- });
798
- }
799
- const exporter = registry.exporter(artifact.kind, request.format, locale);
403
+ const exporter = registry.exporter(artifact.format, request.format, locale);
800
404
  const warnings: string[] = [];
801
405
  const timeout = AbortSignal.timeout(request.timeoutMs ?? options.exportTimeoutMs ?? 60_000);
802
406
  const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
@@ -816,8 +420,8 @@ export function createArtifactService(options: ArtifactServiceOptions): Artifact
816
420
  signal,
817
421
  );
818
422
  } catch (cause) {
819
- metrics.rejection?.('EXPORT_FAILED', fields(artifact, scope, { format: request.format }));
820
- logger.error('artifact.export_failed', fields(artifact, scope, { format: request.format }));
423
+ metrics.rejection?.('EXPORT_FAILED', fields(artifact, scope, { target: request.format }));
424
+ logger.error('artifact.export_failed', fields(artifact, scope, { target: request.format }));
821
425
  throw new ArtifactError('EXPORT_FAILED', t(locale, 'EXPORT_FAILED', { format: request.format }), {
822
426
  cause,
823
427
  });
@@ -846,7 +450,7 @@ export function createArtifactService(options: ArtifactServiceOptions): Artifact
846
450
  const duration = Date.now() - started;
847
451
  metrics.exportDuration?.(
848
452
  duration,
849
- fields(artifact, scope, { format: request.format, durationMs: duration }),
453
+ fields(artifact, scope, { target: request.format, durationMs: duration }),
850
454
  );
851
455
  events.emit({
852
456
  type: 'artifact.exported',
@@ -900,4 +504,4 @@ export function slugify(title: string): string {
900
504
  return slug || 'artifact';
901
505
  }
902
506
 
903
- export { deepEqual, envelopeOperationSchema };
507
+ export type { AnyFormatAdapter };