@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.
- package/dist/adapter.d.ts +119 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +45 -0
- package/dist/adapter.js.map +1 -0
- package/dist/diff.d.ts +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/engine.d.ts +29 -23
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +148 -197
- package/dist/engine.js.map +1 -1
- package/dist/envelope.d.ts +10 -27
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +8 -20
- package/dist/envelope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -4
- package/dist/errors.js.map +1 -1
- package/dist/events.d.ts +2 -10
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js.map +1 -1
- package/dist/i18n.d.ts +8 -14
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +12 -18
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +1 -2
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js.map +1 -1
- package/dist/memory.d.ts +0 -2
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +15 -109
- package/dist/memory.js.map +1 -1
- package/dist/migrations.d.ts +6 -5
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +20 -14
- package/dist/migrations.js.map +1 -1
- package/dist/projection.d.ts +22 -6
- package/dist/projection.d.ts.map +1 -1
- package/dist/projection.js +5 -5
- package/dist/projection.js.map +1 -1
- package/dist/registry.d.ts +17 -16
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +25 -25
- package/dist/registry.js.map +1 -1
- package/dist/service.d.ts +19 -51
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +119 -404
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +12 -61
- package/dist/store.d.ts.map +1 -1
- package/dist/testing/index.d.ts +10 -9
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +75 -136
- package/dist/testing/index.js.map +1 -1
- package/dist/theme.js +1 -1
- package/dist/ui.d.ts +18 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +5 -3
- package/dist/ui.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter.ts +180 -0
- package/src/diff.ts +1 -1
- package/src/engine.ts +183 -230
- package/src/envelope.ts +11 -29
- package/src/errors.ts +4 -4
- package/src/events.ts +2 -11
- package/src/i18n.ts +14 -20
- package/src/index.ts +2 -4
- package/src/logger.ts +1 -2
- package/src/memory.ts +16 -122
- package/src/migrations.ts +24 -18
- package/src/projection.ts +25 -7
- package/src/registry.ts +45 -41
- package/src/service.ts +144 -540
- package/src/store.ts +12 -64
- package/src/testing/index.ts +89 -164
- package/src/theme.ts +1 -1
- package/src/ui.ts +24 -4
- package/dist/kind.d.ts +0 -156
- package/dist/kind.d.ts.map +0 -1
- package/dist/kind.js +0 -52
- package/dist/kind.js.map +0 -1
- 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
|
|
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
|
-
|
|
70
|
-
'
|
|
71
|
-
|
|
72
|
-
{
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
/**
|
|
62
|
+
/** Adapter validation + limits. Throws INVALID_CONTENT or LIMIT_EXCEEDED. Returns the normalized content. */
|
|
90
63
|
export function validateContent(
|
|
91
64
|
registry: ArtifactRegistry,
|
|
92
|
-
|
|
65
|
+
adapter: AnyFormatAdapter,
|
|
93
66
|
raw: unknown,
|
|
94
|
-
|
|
67
|
+
ctx: AdapterContext,
|
|
95
68
|
): unknown {
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
98
|
-
throw new ArtifactError('INVALID_CONTENT', t(locale, 'INVALID_CONTENT'), {
|
|
99
|
-
issues:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
88
|
+
adapter: AnyFormatAdapter,
|
|
126
89
|
content: unknown,
|
|
127
90
|
locale?: string,
|
|
128
91
|
): void {
|
|
129
|
-
const limits = registry.limits(
|
|
130
|
-
const measured =
|
|
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 →
|
|
113
|
+
* sanitize → validate → limits → lint. Pure.
|
|
146
114
|
*/
|
|
147
115
|
export function prepareContent(
|
|
148
116
|
registry: ArtifactRegistry,
|
|
149
|
-
|
|
117
|
+
formatName: string,
|
|
150
118
|
raw: unknown,
|
|
151
|
-
options:
|
|
119
|
+
options: { locale?: string; themeId?: string } = {},
|
|
152
120
|
): PreparedContent {
|
|
153
|
-
const locale = options.locale;
|
|
154
|
-
const
|
|
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
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
throw new ArtifactError('
|
|
164
|
-
issues:
|
|
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
|
-
|
|
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
|
-
|
|
149
|
+
formatVersion: string;
|
|
194
150
|
title: string;
|
|
195
151
|
themeId: string;
|
|
196
152
|
locale: string;
|
|
197
|
-
/**
|
|
198
|
-
|
|
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
|
-
|
|
162
|
+
formatName: string,
|
|
207
163
|
operations: unknown,
|
|
208
164
|
locale?: string,
|
|
209
|
-
):
|
|
210
|
-
const
|
|
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(
|
|
202
|
+
const names = [...ENVELOPE_OPERATIONS, ...operationNames(adapter.operationSchema)];
|
|
247
203
|
return operations.map((raw, index) => {
|
|
248
204
|
const op = sanitizeDeep(raw);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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, '
|
|
253
|
+
artifact: Pick<Artifact, 'format' | 'content' | 'title' | 'themeId' | 'locale'>,
|
|
286
254
|
operations: unknown,
|
|
287
|
-
options:
|
|
288
|
-
): BatchResult {
|
|
255
|
+
options: { locale?: string } = {},
|
|
256
|
+
): Promise<BatchResult> {
|
|
289
257
|
const locale = options.locale ?? artifact.locale;
|
|
290
|
-
const
|
|
291
|
-
const
|
|
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
|
-
|
|
297
|
-
const
|
|
263
|
+
let draft: unknown = structuredClone(before);
|
|
264
|
+
const createdRefs: string[] = [];
|
|
265
|
+
const warnings: LintIssue[] = [];
|
|
298
266
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
|
|
331
|
+
formatVersion: adapter.formatVersion,
|
|
373
332
|
...envelope,
|
|
374
|
-
|
|
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
|
-
...
|
|
342
|
+
...adapter.diff(before as never, draft as never, locale),
|
|
384
343
|
],
|
|
385
|
-
lint:
|
|
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
|
-
):
|
|
376
|
+
): void {
|
|
399
377
|
switch (op.op) {
|
|
400
378
|
case 'set_title':
|
|
401
379
|
envelope.title = op.title;
|
|
402
|
-
return
|
|
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
|
|
400
|
+
return;
|
|
423
401
|
case 'set_locale':
|
|
424
402
|
envelope.locale = op.locale;
|
|
425
|
-
return
|
|
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
|
-
*
|
|
447
|
-
*
|
|
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
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|