@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/dist/service.js CHANGED
@@ -1,6 +1,5 @@
1
- import { deepEqual } from './diff.js';
2
- import { applyOperations, batchTargets, envelopeOperationSchema, parseOperations, prepareContent, targetsOverlap, } from './engine.js';
3
- import { canTransition, summarize, titleSchema, } from './envelope.js';
1
+ import { applyOperations, prepareContent } from './engine.js';
2
+ import { summarize, titleSchema } from './envelope.js';
4
3
  import { ArtifactError, zodIssuesToErrorIssues } from './errors.js';
5
4
  import { createEventBus } from './events.js';
6
5
  import { t } from './i18n.js';
@@ -8,14 +7,9 @@ import { silentLogger } from './logger.js';
8
7
  import { migrateContent } from './migrations.js';
9
8
  import { delimitProjection } from './projection.js';
10
9
  import { sanitizeDeep } from './sanitize.js';
11
- export const DEFAULT_APPROVAL_POLICY = {
12
- requireApprovalToPublish: true,
13
- requireApprovalToExport: false,
14
- requireApprovalToEditPublished: false,
15
- };
16
10
  /**
17
- * The high-level pipeline (spec §8.2): authorization, migration, baseVersion check with automatic rebase,
18
- * atomic apply, optimistic commit with retries, idempotency, events and metadata-only logging.
11
+ * The core pipeline (spec §9.1): authorization, migration, baseVersion check, atomic apply, optimistic commit, events
12
+ * and metadata-only logging. There is no history, approval or archive here: the harness decides what a save means.
19
13
  */
20
14
  export function createArtifactService(options) {
21
15
  const { registry, store } = options;
@@ -24,8 +18,6 @@ export function createArtifactService(options) {
24
18
  const metrics = options.metrics ?? {};
25
19
  const events = options.events ??
26
20
  createEventBus((error) => logger.error('event handler failed', { code: String(error) }));
27
- const policy = { ...DEFAULT_APPROVAL_POLICY, ...options.policy };
28
- const retries = options.commitRetries ?? 3;
29
21
  async function authorize(action, artifact, scope) {
30
22
  if (!options.authorize)
31
23
  return;
@@ -35,10 +27,10 @@ export function createArtifactService(options) {
35
27
  throw new ArtifactError('PERMISSION_DENIED', t(locale, 'PERMISSION_DENIED', { action }));
36
28
  }
37
29
  }
38
- async function load(scope, id, version) {
39
- const found = await store.get(scope, id, version === undefined ? undefined : { version });
30
+ async function load(scope, id) {
31
+ const found = await store.get(scope, id);
40
32
  if (!found) {
41
- throw new ArtifactError('NOT_FOUND', t(locale, 'NOT_FOUND', { what: version ? `Version ${version}` : 'Artifact', id }), {
33
+ throw new ArtifactError('NOT_FOUND', t(locale, 'NOT_FOUND', { what: 'Artifact', id }), {
42
34
  issues: [
43
35
  {
44
36
  path: 'id',
@@ -51,16 +43,16 @@ export function createArtifactService(options) {
51
43
  return migrated(found);
52
44
  }
53
45
  function migrated(artifact) {
54
- const kind = registry.get(artifact.kind, locale);
55
- const result = migrateContent(kind, artifact.content, artifact.schemaVersion, locale);
46
+ const adapter = registry.get(artifact.format, locale);
47
+ const result = migrateContent(adapter, artifact.content, artifact.formatVersion, locale);
56
48
  return result.migrated
57
- ? { ...artifact, content: result.content, schemaVersion: result.schemaVersion }
49
+ ? { ...artifact, content: result.content, formatVersion: result.formatVersion }
58
50
  : artifact;
59
51
  }
60
52
  function fields(artifact, scope, extra = {}) {
61
53
  return {
62
54
  artifactId: artifact.id,
63
- kind: artifact.kind,
55
+ format: artifact.format,
64
56
  version: artifact.version,
65
57
  actorType: scope.actor.type,
66
58
  ...extra,
@@ -83,154 +75,36 @@ export function createArtifactService(options) {
83
75
  details: { expiresAt: lock.expiresAt },
84
76
  });
85
77
  }
86
- async function replay(scope, id, key) {
87
- if (!key)
88
- return undefined;
89
- const entry = await store.findByIdempotencyKey(scope, id, key);
90
- if (!entry)
91
- return undefined;
92
- const artifact = await load(scope, id, entry.version);
93
- const kind = registry.get(artifact.kind, locale);
94
- return {
95
- artifact,
96
- version: entry.version,
97
- createdIds: entry.createdIds,
98
- diff: entry.diff,
99
- lint: kind.lint(artifact.content, {
100
- locale: artifact.locale,
101
- theme: registry.theme(artifact.themeId, locale),
102
- }),
103
- rebased: false,
104
- replayed: true,
105
- };
106
- }
107
- /** Applies on the current version, rebasing from an older base when the touched items did not change. */
108
- async function computeBatch(scope, current, request) {
109
- if (request.baseVersion === current.version) {
110
- return { result: applyOperations(registry, current, request.operations, { locale }), rebased: false };
111
- }
112
- const conflict = (changes) => {
113
- metrics.conflict?.(fields(current, scope));
114
- events.emit({
115
- type: 'artifact.conflict',
116
- artifactId: current.id,
117
- baseVersion: request.baseVersion,
118
- currentVersion: current.version,
119
- actor: scope.actor,
120
- });
121
- return new ArtifactError('VERSION_CONFLICT', t(locale, 'VERSION_CONFLICT', { baseVersion: request.baseVersion, currentVersion: current.version }), {
122
- issues: [
123
- {
124
- path: 'baseVersion',
125
- message: t(locale, 'VERSION_CONFLICT', {
126
- baseVersion: request.baseVersion,
127
- currentVersion: current.version,
128
- }),
129
- hint: t(locale, 'VERSION_CONFLICT_HINT'),
130
- },
131
- ],
132
- details: { currentVersion: current.version, changesSinceBase: changes },
133
- });
134
- };
135
- if (request.baseVersion > current.version || request.baseVersion < 1)
136
- throw conflict([]);
137
- const base = await load(scope, current.id, request.baseVersion);
138
- const kind = registry.get(current.kind, locale);
139
- // Validate against the base first: an invalid batch is the model's error, not a conflict.
140
- const onBase = applyOperations(registry, base, request.operations, { locale });
141
- const changedSince = [
142
- ...(base.title !== current.title
143
- ? [
144
- {
145
- type: 'changed',
146
- target: 'envelope:title',
147
- summary: `'${base.title}' → '${current.title}'`,
148
- },
149
- ]
150
- : []),
151
- ...(base.themeId !== current.themeId
152
- ? [
153
- {
154
- type: 'changed',
155
- target: 'envelope:themeId',
156
- summary: `${base.themeId} → ${current.themeId}`,
157
- },
158
- ]
159
- : []),
160
- ...(base.locale !== current.locale
161
- ? [
162
- {
163
- type: 'changed',
164
- target: 'envelope:locale',
165
- summary: `${base.locale} → ${current.locale}`,
166
- },
167
- ]
168
- : []),
169
- ...kind.diff(base.content, current.content, locale),
170
- ];
171
- const parsed = parseOperations(registry, current.kind, request.operations, locale);
172
- const touched = batchTargets(kind, parsed, onBase.diff);
173
- const changed = new Set(changedSince.map((change) => change.target));
174
- if (targetsOverlap(touched, changed))
175
- throw conflict(changedSince);
176
- try {
177
- return { result: applyOperations(registry, current, request.operations, { locale }), rebased: true };
178
- }
179
- catch (error) {
180
- if (error instanceof ArtifactError &&
181
- (error.code === 'OPERATION_FAILED' || error.code === 'INVALID_CONTENT')) {
182
- throw conflict(changedSince);
183
- }
184
- throw error;
185
- }
186
- }
187
- async function changeStatus(scope, id, to, action, message) {
188
- const current = await load(scope, id);
189
- await authorize(action, summarize(current), scope);
190
- if (!canTransition(current.status, to)) {
191
- throw new ArtifactError('PERMISSION_DENIED', t(locale, 'INVALID_STATUS_TRANSITION', { from: current.status, to }), {
192
- details: { from: current.status, to },
193
- });
194
- }
195
- const updated = await store.setStatus(scope, id, to, scope.actor, message);
78
+ /** The batch must be computed against the current version; a stale base is a conflict, never a silent rebase. */
79
+ function assertBase(scope, current, baseVersion) {
80
+ if (baseVersion === current.version)
81
+ return;
82
+ metrics.conflict?.(fields(current, scope));
196
83
  events.emit({
197
- type: 'artifact.status_changed',
198
- artifact: summarize(updated),
199
- from: current.status,
200
- to,
84
+ type: 'artifact.conflict',
85
+ artifactId: current.id,
86
+ baseVersion,
87
+ currentVersion: current.version,
201
88
  actor: scope.actor,
202
- ...(message ? { message } : {}),
203
89
  });
204
- logger.info('artifact.status_changed', fields(updated, scope, { from: current.status, to }));
205
- return updated;
90
+ const message = t(locale, 'VERSION_CONFLICT', { baseVersion, currentVersion: current.version });
91
+ throw new ArtifactError('VERSION_CONFLICT', message, {
92
+ issues: [
93
+ {
94
+ path: 'baseVersion',
95
+ message,
96
+ hint: t(locale, 'VERSION_CONFLICT_HINT'),
97
+ },
98
+ ],
99
+ details: { currentVersion: current.version },
100
+ });
206
101
  }
207
102
  const service = {
208
103
  registry,
209
104
  events,
210
105
  async create(scope, request) {
211
106
  await authorize('create', null, scope);
212
- const kind = registry.get(request.kind, locale);
213
- if (request.idempotencyKey && store.findCreatedByIdempotencyKey) {
214
- const existing = await store.findCreatedByIdempotencyKey(scope, request.idempotencyKey);
215
- if (existing) {
216
- const first = await load(scope, existing.id, 1);
217
- const prepared = [...registry.get(first.kind, locale).ids(first.content)];
218
- return {
219
- artifact: first,
220
- version: 1,
221
- createdIds: prepared,
222
- diff: [
223
- { type: 'added', target: `artifact:${first.id}`, summary: `${kind.label} "${first.title}"` },
224
- ],
225
- lint: kind.lint(first.content, {
226
- locale: first.locale,
227
- theme: registry.theme(first.themeId, locale),
228
- }),
229
- rebased: false,
230
- replayed: true,
231
- };
232
- }
233
- }
107
+ const adapter = registry.get(request.format, locale);
234
108
  const title = titleSchema.safeParse(sanitizeDeep(request.title));
235
109
  if (!title.success) {
236
110
  throw new ArtifactError('INVALID_CONTENT', t(locale, 'INVALID_CONTENT'), {
@@ -240,295 +114,148 @@ export function createArtifactService(options) {
240
114
  const artifactLocale = request.locale ?? options.defaultArtifactLocale ?? 'en';
241
115
  const themeId = request.themeId ?? options.defaultThemeId ?? 'default';
242
116
  registry.theme(themeId, locale);
243
- let raw;
244
- const inputNotes = [];
245
- if (request.input) {
246
- const input = kind.inputs?.[request.input.format];
247
- if (!input) {
248
- const available = Object.keys(kind.inputs ?? {});
249
- throw new ArtifactError('INVALID_CONTENT', t(locale, 'INPUT_FORMAT_UNSUPPORTED', { kind: kind.kind, format: request.input.format }), {
250
- issues: [
251
- {
252
- path: 'input.format',
253
- message: t(locale, 'INPUT_FORMAT_UNSUPPORTED', {
254
- kind: kind.kind,
255
- format: request.input.format,
256
- }),
257
- hint: t(locale, 'INPUT_FORMAT_HINT', { available: available.join(', ') || '—' }),
258
- },
259
- ],
260
- });
261
- }
262
- const parsedInput = input.schema.safeParse(sanitizeDeep(request.input.source));
263
- if (!parsedInput.success) {
264
- throw new ArtifactError('INVALID_CONTENT', t(locale, 'INVALID_CONTENT'), {
265
- issues: zodIssuesToErrorIssues(parsedInput.error.issues, locale, 'input.source'),
266
- });
267
- }
268
- raw = input.toContent(parsedInput.data, {
269
- locale: artifactLocale,
270
- note: (message) => inputNotes.push(message),
271
- });
272
- }
273
- else {
274
- raw = request.content ?? kind.empty();
275
- }
276
- const prepared = prepareContent(registry, kind.kind, raw, { locale, theme: themeId });
277
- prepared.lint.push(...inputNotes.map((message) => ({
278
- code: 'input-note',
279
- severity: 'info',
280
- target: `input:${request.input?.format ?? 'content'}`,
281
- message,
282
- })));
117
+ const base = {
118
+ id: '',
119
+ format: adapter.format,
120
+ formatVersion: adapter.formatVersion,
121
+ version: 1,
122
+ title: title.data,
123
+ locale: artifactLocale,
124
+ themeId,
125
+ content: request.content ?? adapter.empty(),
126
+ };
127
+ // Creation with an initial batch goes through the same pipeline as any edit.
128
+ const applied = request.operations?.length
129
+ ? await applyOperations(registry, base, request.operations, { locale })
130
+ : undefined;
131
+ const prepared = applied
132
+ ? { content: applied.content, lint: applied.lint }
133
+ : prepareContent(registry, request.format, base.content, { locale: artifactLocale, themeId });
283
134
  const artifact = await store.create(scope, {
284
- kind: kind.kind,
285
- schemaVersion: kind.version,
135
+ format: adapter.format,
136
+ formatVersion: adapter.formatVersion,
286
137
  title: title.data,
287
- status: 'draft',
288
138
  locale: artifactLocale,
289
139
  themeId,
290
140
  content: prepared.content,
291
- createdIds: prepared.ids,
292
141
  ...(request.tags ? { tags: request.tags } : {}),
293
- ...(request.message ? { message: request.message } : {}),
294
- ...(request.idempotencyKey ? { idempotencyKey: request.idempotencyKey } : {}),
295
142
  });
296
143
  events.emit({ type: 'artifact.created', artifact: summarize(artifact), actor: scope.actor });
297
- if (prepared.lint.length > 0)
144
+ if (prepared.lint.length > 0) {
298
145
  events.emit({
299
146
  type: 'artifact.lint',
300
147
  artifactId: artifact.id,
301
148
  version: artifact.version,
302
149
  warnings: prepared.lint.length,
303
150
  });
151
+ }
304
152
  logger.info('artifact.created', fields(artifact, scope));
305
153
  return {
306
154
  artifact,
307
155
  version: artifact.version,
308
- createdIds: prepared.ids,
156
+ createdRefs: applied?.createdRefs ?? [],
309
157
  diff: [
310
- { type: 'added', target: `artifact:${artifact.id}`, summary: `${kind.label} "${artifact.title}"` },
158
+ {
159
+ type: 'added',
160
+ target: `artifact:${artifact.id}`,
161
+ summary: `${adapter.label} "${artifact.title}"`,
162
+ },
311
163
  ],
312
164
  lint: prepared.lint,
313
- rebased: false,
314
- replayed: false,
315
165
  };
316
166
  },
317
- async get(scope, id, opts) {
318
- const artifact = await load(scope, id, opts?.version);
167
+ async get(scope, id) {
168
+ const artifact = await load(scope, id);
319
169
  await authorize('read', summarize(artifact), scope);
320
170
  return artifact;
321
171
  },
322
172
  async read(scope, id, opts = {}) {
323
- const artifact = await load(scope, id, opts.version);
173
+ const artifact = await load(scope, id);
324
174
  await authorize('read', summarize(artifact), scope);
325
- const kind = registry.get(artifact.kind, locale);
326
- const projection = kind.project(artifact.content, {
175
+ const adapter = registry.get(artifact.format, locale);
176
+ const result = adapter.read(artifact.content, {
327
177
  mode: opts.mode ?? 'outline',
328
178
  locale: artifact.locale,
329
- theme: registry.theme(artifact.themeId, locale),
330
179
  ...(opts.range === undefined ? {} : { range: opts.range }),
331
180
  ...(opts.maxTokens === undefined ? {} : { maxTokens: opts.maxTokens }),
332
181
  ...(opts.cursor === undefined ? {} : { cursor: opts.cursor }),
333
182
  ...(opts.charsPerToken === undefined ? {} : { charsPerToken: opts.charsPerToken }),
334
183
  });
335
- const header = `${kind.label} "${artifact.title}" (id: ${artifact.id}, kind: ${artifact.kind}, version: ${artifact.version}, status: ${artifact.status}, locale: ${artifact.locale})`;
184
+ const projection = {
185
+ text: result.text,
186
+ truncated: result.truncated,
187
+ ...(result.cursor === undefined ? {} : { cursor: result.cursor }),
188
+ };
189
+ const header = `${adapter.label} "${artifact.title}" (id: ${artifact.id}, format: ${artifact.format}, version: ${artifact.version}, locale: ${artifact.locale})`;
336
190
  let text = `${header}\n${delimitProjection(artifact.id, projection.text)}`;
337
191
  if (projection.truncated) {
338
- text += `\n[truncated — continue with cursor "${projection.cursor}"]\nRemaining outline:\n${projection.remainingOutline ?? ''}`;
192
+ text += `\n[truncated — continue with cursor "${projection.cursor}"]`;
339
193
  }
340
194
  return { artifact: summarize(artifact), version: artifact.version, projection, text };
341
195
  },
342
196
  async apply(scope, request) {
343
197
  const started = Date.now();
344
- let current = await load(scope, request.artifactId);
198
+ const current = await load(scope, request.artifactId);
345
199
  await authorize('apply', summarize(current), scope);
346
- const replayed = await replay(scope, current.id, request.idempotencyKey);
347
- if (replayed)
348
- return replayed;
349
200
  await assertNotLocked(scope, current.id);
350
- if (current.status === 'published' &&
351
- policy.requireApprovalToEditPublished &&
352
- scope.actor.type === 'agent') {
353
- throw new ArtifactError('APPROVAL_REQUIRED', t(locale, 'APPROVAL_REQUIRED', { action: 'apply' }), {
354
- issues: [
355
- {
356
- path: 'artifactId',
357
- message: t(locale, 'APPROVAL_REQUIRED', { action: 'apply' }),
358
- hint: t(locale, 'APPROVAL_REQUIRED_HINT'),
359
- },
360
- ],
201
+ assertBase(scope, current, request.baseVersion);
202
+ const result = await applyOperations(registry, current, request.operations, { locale });
203
+ let committed;
204
+ try {
205
+ committed = await store.commit(scope, current.id, {
206
+ baseVersion: current.version,
207
+ formatVersion: result.formatVersion,
208
+ content: result.content,
209
+ title: result.title,
210
+ locale: result.locale,
211
+ themeId: result.themeId,
361
212
  });
362
213
  }
363
- for (let attempt = 0;; attempt++) {
364
- const { result, rebased } = await computeBatch(scope, current, request);
365
- try {
366
- const committed = await store.commit(scope, current.id, {
367
- baseVersion: current.version,
368
- schemaVersion: result.schemaVersion,
369
- content: result.content,
370
- title: result.title,
371
- locale: result.locale,
372
- themeId: result.themeId,
373
- // Editing a published (or pending) artifact starts a new draft (spec §15.3).
374
- ...(current.status === 'published' || current.status === 'pending_approval'
375
- ? { status: 'draft' }
376
- : {}),
377
- operations: request.operations,
378
- diff: result.diff,
379
- createdIds: result.createdIds,
380
- ...(request.message ? { message: request.message } : {}),
381
- ...(request.idempotencyKey ? { idempotencyKey: request.idempotencyKey } : {}),
382
- });
383
- const duration = Date.now() - started;
384
- const logFields = fields(committed, scope, {
385
- operationCount: request.operations.length,
386
- durationMs: duration,
387
- });
388
- metrics.applyDuration?.(duration, logFields);
389
- logger.info('artifact.committed', logFields);
390
- events.emit({
391
- type: 'artifact.committed',
392
- artifact: summarize(committed),
393
- actor: scope.actor,
394
- operationCount: request.operations.length,
395
- diff: result.diff,
396
- rebased,
397
- });
398
- if (result.lint.length > 0)
399
- events.emit({
400
- type: 'artifact.lint',
401
- artifactId: committed.id,
402
- version: committed.version,
403
- warnings: result.lint.length,
404
- });
405
- return {
406
- artifact: committed,
407
- version: committed.version,
408
- createdIds: result.createdIds,
409
- diff: result.diff,
410
- lint: result.lint,
411
- rebased,
412
- replayed: false,
413
- };
414
- }
415
- catch (error) {
416
- // Another writer won the race: reload and rebase from the original base.
417
- if (error instanceof ArtifactError && error.code === 'VERSION_CONFLICT' && attempt < retries) {
418
- current = await load(scope, request.artifactId);
419
- continue;
420
- }
421
- if (error instanceof ArtifactError)
422
- metrics.rejection?.(error.code, fields(current, scope));
423
- throw error;
424
- }
214
+ catch (error) {
215
+ if (error instanceof ArtifactError)
216
+ metrics.rejection?.(error.code, fields(current, scope));
217
+ throw error;
425
218
  }
426
- },
427
- async preview(scope, request) {
428
- const current = await load(scope, request.artifactId);
429
- await authorize('read', summarize(current), scope);
430
- return (await computeBatch(scope, current, request)).result;
431
- },
432
- async list(scope, filter = {}, page = {}) {
433
- await authorize('list', null, scope);
434
- return store.list(scope, filter, page);
435
- },
436
- async history(scope, id, page = {}) {
437
- const current = await load(scope, id);
438
- await authorize('history', summarize(current), scope);
439
- return store.history(scope, id, page);
440
- },
441
- async restore(scope, id, version, message) {
442
- const current = await load(scope, id);
443
- await authorize('restore', summarize(current), scope);
444
- await assertNotLocked(scope, id);
445
- const old = await load(scope, id, version);
446
- const kind = registry.get(current.kind, locale);
447
- const diff = [
448
- ...(old.title !== current.title
449
- ? [
450
- {
451
- type: 'changed',
452
- target: 'envelope:title',
453
- summary: `'${current.title}' → '${old.title}'`,
454
- },
455
- ]
456
- : []),
457
- ...kind.diff(current.content, old.content, locale),
458
- ];
459
- const beforeIds = new Set(kind.ids(current.content));
460
- const createdIds = [...kind.ids(old.content)].filter((itemId) => !beforeIds.has(itemId));
461
- const committed = await store.commit(scope, id, {
462
- baseVersion: current.version,
463
- schemaVersion: old.schemaVersion,
464
- content: old.content,
465
- title: old.title,
466
- locale: old.locale,
467
- themeId: old.themeId,
468
- ...(current.status !== 'draft' && current.status !== 'archived' ? { status: 'draft' } : {}),
469
- operations: [{ op: 'replace_content', content: '[restored]' }],
470
- diff,
471
- createdIds,
472
- restoredFrom: version,
473
- message: message ?? `Restored version ${version}`,
219
+ const duration = Date.now() - started;
220
+ const logFields = fields(committed, scope, {
221
+ operationCount: request.operations.length,
222
+ durationMs: duration,
474
223
  });
224
+ metrics.applyDuration?.(duration, logFields);
225
+ logger.info('artifact.committed', logFields);
475
226
  events.emit({
476
227
  type: 'artifact.committed',
477
228
  artifact: summarize(committed),
478
229
  actor: scope.actor,
479
- operationCount: 0,
480
- diff,
481
- rebased: false,
230
+ operationCount: request.operations.length,
231
+ diff: result.diff,
482
232
  });
233
+ if (result.lint.length > 0) {
234
+ events.emit({
235
+ type: 'artifact.lint',
236
+ artifactId: committed.id,
237
+ version: committed.version,
238
+ warnings: result.lint.length,
239
+ });
240
+ }
483
241
  return {
484
242
  artifact: committed,
485
243
  version: committed.version,
486
- createdIds,
487
- diff,
488
- lint: kind.lint(committed.content, {
489
- locale: committed.locale,
490
- theme: registry.theme(committed.themeId, locale),
491
- }),
492
- rebased: false,
493
- replayed: false,
244
+ createdRefs: result.createdRefs,
245
+ diff: result.diff,
246
+ lint: result.lint,
494
247
  };
495
248
  },
496
- submit: (scope, id, message) => changeStatus(scope, id, 'pending_approval', 'submit', message),
497
- async approve(scope, id, message) {
498
- if (scope.actor.type === 'agent') {
499
- throw new ArtifactError('PERMISSION_DENIED', t(locale, 'PERMISSION_DENIED', { action: 'approve' }));
500
- }
501
- const current = await load(scope, id);
502
- if (current.status !== 'pending_approval') {
503
- throw new ArtifactError('PERMISSION_DENIED', t(locale, 'INVALID_STATUS_TRANSITION', { from: current.status, to: 'published' }));
504
- }
505
- return changeStatus(scope, id, 'published', 'approve', message);
506
- },
507
- async reject(scope, id, reason) {
508
- if (scope.actor.type === 'agent') {
509
- throw new ArtifactError('PERMISSION_DENIED', t(locale, 'PERMISSION_DENIED', { action: 'reject' }));
510
- }
511
- const current = await load(scope, id);
512
- if (current.status !== 'pending_approval') {
513
- throw new ArtifactError('PERMISSION_DENIED', t(locale, 'INVALID_STATUS_TRANSITION', { from: current.status, to: 'draft' }));
514
- }
515
- return changeStatus(scope, id, 'draft', 'reject', reason);
249
+ async preview(scope, request) {
250
+ const current = await load(scope, request.artifactId);
251
+ await authorize('read', summarize(current), scope);
252
+ assertBase(scope, current, request.baseVersion);
253
+ return applyOperations(registry, current, request.operations, { locale });
516
254
  },
517
- async publish(scope, id) {
518
- if (policy.requireApprovalToPublish) {
519
- throw new ArtifactError('APPROVAL_REQUIRED', t(locale, 'APPROVAL_REQUIRED', { action: 'publish' }), {
520
- issues: [
521
- {
522
- path: 'id',
523
- message: t(locale, 'APPROVAL_REQUIRED', { action: 'publish' }),
524
- hint: t(locale, 'APPROVAL_REQUIRED_HINT'),
525
- },
526
- ],
527
- });
528
- }
529
- return changeStatus(scope, id, 'published', 'publish');
255
+ async list(scope, filter = {}, page = {}) {
256
+ await authorize('list', null, scope);
257
+ return store.list(scope, filter, page);
530
258
  },
531
- archive: (scope, id) => changeStatus(scope, id, 'archived', 'archive'),
532
259
  async lock(scope, id, ttlMs = 5 * 60_000) {
533
260
  const current = await load(scope, id);
534
261
  await authorize('lock', summarize(current), scope);
@@ -542,20 +269,9 @@ export function createArtifactService(options) {
542
269
  },
543
270
  async export(scope, request) {
544
271
  const started = Date.now();
545
- const artifact = await load(scope, request.artifactId, request.version);
272
+ const artifact = await load(scope, request.artifactId);
546
273
  await authorize('export', summarize(artifact), scope);
547
- if (policy.requireApprovalToExport && artifact.status !== 'published') {
548
- throw new ArtifactError('APPROVAL_REQUIRED', t(locale, 'APPROVAL_REQUIRED', { action: 'export' }), {
549
- issues: [
550
- {
551
- path: 'artifactId',
552
- message: t(locale, 'APPROVAL_REQUIRED', { action: 'export' }),
553
- hint: t(locale, 'APPROVAL_REQUIRED_HINT'),
554
- },
555
- ],
556
- });
557
- }
558
- const exporter = registry.exporter(artifact.kind, request.format, locale);
274
+ const exporter = registry.exporter(artifact.format, request.format, locale);
559
275
  const warnings = [];
560
276
  const timeout = AbortSignal.timeout(request.timeoutMs ?? options.exportTimeoutMs ?? 60_000);
561
277
  const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
@@ -573,8 +289,8 @@ export function createArtifactService(options) {
573
289
  }), signal);
574
290
  }
575
291
  catch (cause) {
576
- metrics.rejection?.('EXPORT_FAILED', fields(artifact, scope, { format: request.format }));
577
- logger.error('artifact.export_failed', fields(artifact, scope, { format: request.format }));
292
+ metrics.rejection?.('EXPORT_FAILED', fields(artifact, scope, { target: request.format }));
293
+ logger.error('artifact.export_failed', fields(artifact, scope, { target: request.format }));
578
294
  throw new ArtifactError('EXPORT_FAILED', t(locale, 'EXPORT_FAILED', { format: request.format }), {
579
295
  cause,
580
296
  });
@@ -601,7 +317,7 @@ export function createArtifactService(options) {
601
317
  result.asset = { ref: stored.ref, size: stored.size, hash: stored.hash };
602
318
  }
603
319
  const duration = Date.now() - started;
604
- metrics.exportDuration?.(duration, fields(artifact, scope, { format: request.format, durationMs: duration }));
320
+ metrics.exportDuration?.(duration, fields(artifact, scope, { target: request.format, durationMs: duration }));
605
321
  events.emit({
606
322
  type: 'artifact.exported',
607
323
  artifact: summarize(artifact),
@@ -648,5 +364,4 @@ export function slugify(title) {
648
364
  .slice(0, 80);
649
365
  return slug || 'artifact';
650
366
  }
651
- export { deepEqual, envelopeOperationSchema };
652
367
  //# sourceMappingURL=service.js.map