@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/store.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { AssetMeta, AssetRef, StoredAssetInfo } from './assets.js';
|
|
2
|
-
import type { Actor, Artifact,
|
|
3
|
-
import type { ChangeSummary } from './kind.js';
|
|
2
|
+
import type { Actor, Artifact, ArtifactSummary } from './envelope.js';
|
|
4
3
|
|
|
5
4
|
/** Workspace and actor. Every store implementation filters by scope; ids alone never grant access. */
|
|
6
5
|
export interface Scope {
|
|
@@ -11,60 +10,23 @@ export interface Scope {
|
|
|
11
10
|
export type StoredArtifact = Artifact;
|
|
12
11
|
|
|
13
12
|
export interface NewArtifact {
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
format: string;
|
|
14
|
+
formatVersion: string;
|
|
16
15
|
title: string;
|
|
17
|
-
status: ArtifactStatus;
|
|
18
16
|
locale: string;
|
|
19
17
|
themeId: string;
|
|
20
18
|
content: unknown;
|
|
21
19
|
tags?: string[];
|
|
22
|
-
message?: string;
|
|
23
|
-
idempotencyKey?: string;
|
|
24
|
-
createdIds?: string[];
|
|
25
20
|
}
|
|
26
21
|
|
|
27
22
|
export interface Commit {
|
|
28
23
|
/** Version the change was computed against. The store rejects with VERSION_CONFLICT when it is stale. */
|
|
29
24
|
baseVersion: number;
|
|
30
|
-
|
|
25
|
+
formatVersion: string;
|
|
31
26
|
content: unknown;
|
|
32
27
|
title: string;
|
|
33
28
|
locale: string;
|
|
34
29
|
themeId: string;
|
|
35
|
-
status?: ArtifactStatus;
|
|
36
|
-
operations: unknown[];
|
|
37
|
-
diff: ChangeSummary[];
|
|
38
|
-
createdIds?: string[];
|
|
39
|
-
message?: string;
|
|
40
|
-
idempotencyKey?: string;
|
|
41
|
-
/** Set when the commit restores an older version (spec §8.5). */
|
|
42
|
-
restoredFrom?: number;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface VersionEntry {
|
|
46
|
-
version: number;
|
|
47
|
-
actor: Actor;
|
|
48
|
-
createdAt: string;
|
|
49
|
-
operations: unknown[];
|
|
50
|
-
diff: ChangeSummary[];
|
|
51
|
-
createdIds: string[];
|
|
52
|
-
status: ArtifactStatus;
|
|
53
|
-
message?: string;
|
|
54
|
-
idempotencyKey?: string;
|
|
55
|
-
restoredFrom?: number;
|
|
56
|
-
/** Lifecycle changes made while this was the current version, oldest first (approval trail). */
|
|
57
|
-
statusLog?: StatusChange[];
|
|
58
|
-
/** Content snapshot, present at least on the first version and periodically after (spec §8.5). */
|
|
59
|
-
snapshot?: { schemaVersion: number; content: unknown };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface StatusChange {
|
|
63
|
-
status: ArtifactStatus;
|
|
64
|
-
actor: Actor;
|
|
65
|
-
at: string;
|
|
66
|
-
/** Submit message, approval note or rejection reason. */
|
|
67
|
-
message?: string;
|
|
68
30
|
}
|
|
69
31
|
|
|
70
32
|
export interface PageRequest {
|
|
@@ -78,8 +40,7 @@ export interface Page<T> {
|
|
|
78
40
|
}
|
|
79
41
|
|
|
80
42
|
export interface ArtifactFilter {
|
|
81
|
-
|
|
82
|
-
status?: ArtifactStatus;
|
|
43
|
+
format?: string;
|
|
83
44
|
/** Case-insensitive substring of the title. */
|
|
84
45
|
title?: string;
|
|
85
46
|
tag?: string;
|
|
@@ -93,35 +54,22 @@ export interface Lock {
|
|
|
93
54
|
}
|
|
94
55
|
|
|
95
56
|
/**
|
|
96
|
-
* Persistence contract (spec §
|
|
97
|
-
*
|
|
57
|
+
* Persistence contract (spec §17.1). The store keeps the current content of each artifact and its version counter;
|
|
58
|
+
* it does not keep earlier versions. Implementations must pass the shared contract suite from `@artkit-ai/core/testing`.
|
|
98
59
|
*/
|
|
99
60
|
export interface ArtifactStore {
|
|
100
|
-
get(scope: Scope, id: string
|
|
61
|
+
get(scope: Scope, id: string): Promise<StoredArtifact | null>;
|
|
101
62
|
create(scope: Scope, input: NewArtifact): Promise<StoredArtifact>;
|
|
102
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Replaces the content when `change.baseVersion` is the current version and increments the version. Throws
|
|
65
|
+
* `ArtifactError('VERSION_CONFLICT')` otherwise.
|
|
66
|
+
*/
|
|
103
67
|
commit(scope: Scope, id: string, change: Commit): Promise<StoredArtifact>;
|
|
104
|
-
history(scope: Scope, id: string, page: PageRequest): Promise<Page<VersionEntry>>;
|
|
105
68
|
list(scope: Scope, filter: ArtifactFilter, page: PageRequest): Promise<Page<ArtifactSummary>>;
|
|
106
|
-
/** Changes status without creating a version; sets `meta.publishedVersion` when publishing. */
|
|
107
|
-
setStatus(
|
|
108
|
-
scope: Scope,
|
|
109
|
-
id: string,
|
|
110
|
-
status: ArtifactStatus,
|
|
111
|
-
actor: Actor,
|
|
112
|
-
message?: string,
|
|
113
|
-
): Promise<StoredArtifact>;
|
|
114
69
|
/** Throws `ArtifactError('LOCKED')` when another actor holds a valid lock. */
|
|
115
70
|
lock(scope: Scope, id: string, actor: Actor, ttlMs: number): Promise<Lock>;
|
|
116
71
|
unlock(scope: Scope, lock: Lock): Promise<void>;
|
|
117
72
|
getLock(scope: Scope, id: string): Promise<Lock | null>;
|
|
118
|
-
findByIdempotencyKey(scope: Scope, id: string, key: string): Promise<VersionEntry | null>;
|
|
119
|
-
/**
|
|
120
|
-
* Finds an artifact created with this idempotency key by the same actor in the scope's workspace, so a repeated
|
|
121
|
-
* `create` tool call returns the first artifact instead of a duplicate. Optional for compatibility;
|
|
122
|
-
* without it, creation is not deduplicated across processes.
|
|
123
|
-
*/
|
|
124
|
-
findCreatedByIdempotencyKey?(scope: Scope, key: string): Promise<StoredArtifact | null>;
|
|
125
73
|
}
|
|
126
74
|
|
|
127
75
|
export interface AssetStore {
|
package/src/testing/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import type { AnyFormatAdapter, FormatAdapter } from '../adapter.js';
|
|
1
2
|
import { deepEqual } from '../diff.js';
|
|
2
3
|
import { applyOperations, prepareContent } from '../engine.js';
|
|
3
4
|
import { ArtifactError } from '../errors.js';
|
|
4
|
-
import type { AnyArtifactKind } from '../kind.js';
|
|
5
5
|
import { createRegistry } from '../registry.js';
|
|
6
|
-
import type { ArtifactStore, NewArtifact, Scope } from '../store.js';
|
|
6
|
+
import type { ArtifactStore, Commit, NewArtifact, Scope } from '../store.js';
|
|
7
7
|
import { defaultTheme } from '../theme.js';
|
|
8
8
|
|
|
9
9
|
/** Minimal test API so the suites work with vitest, jest or node:test. */
|
|
@@ -29,21 +29,30 @@ async function expectCode(promise: Promise<unknown>, code: string): Promise<void
|
|
|
29
29
|
|
|
30
30
|
const scopeA: Scope = { workspaceId: 'ws_a', actor: { type: 'user', id: 'usr_1' } };
|
|
31
31
|
const scopeB: Scope = { workspaceId: 'ws_b', actor: { type: 'user', id: 'usr_2' } };
|
|
32
|
-
const agent: Scope = { workspaceId: 'ws_a', actor: { type: 'agent', id: 'agt_1' } };
|
|
33
32
|
|
|
34
33
|
const sample: NewArtifact = {
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
format: 'contract-text',
|
|
35
|
+
formatVersion: 'contract@1',
|
|
37
36
|
title: 'Contract test',
|
|
38
|
-
status: 'draft',
|
|
39
37
|
locale: 'en',
|
|
40
38
|
themeId: 'default',
|
|
41
|
-
content:
|
|
39
|
+
content: 'hello',
|
|
42
40
|
tags: ['contract'],
|
|
43
41
|
};
|
|
44
42
|
|
|
43
|
+
function commitFrom(baseVersion: number, text = 'v2'): Commit {
|
|
44
|
+
return {
|
|
45
|
+
baseVersion,
|
|
46
|
+
formatVersion: 'contract@1',
|
|
47
|
+
content: text,
|
|
48
|
+
title: 'Contract test',
|
|
49
|
+
locale: 'en',
|
|
50
|
+
themeId: 'default',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
45
54
|
/**
|
|
46
|
-
* Shared contract suite every `ArtifactStore` adapter must pass (spec §
|
|
55
|
+
* Shared contract suite every `ArtifactStore` adapter must pass (spec §17.1).
|
|
47
56
|
* `factory` must return a fresh, empty store for each test.
|
|
48
57
|
*/
|
|
49
58
|
export function defineStoreContractTests(
|
|
@@ -76,159 +85,72 @@ export function defineStoreContractTests(
|
|
|
76
85
|
const created = await store.create(scopeA, sample);
|
|
77
86
|
const v2 = await store.commit(scopeA, created.id, commitFrom(1, 'v2'));
|
|
78
87
|
assert(v2.version === 2, 'second version');
|
|
88
|
+
assert(v2.content === 'v2', 'the current content is replaced');
|
|
79
89
|
await expectCode(store.commit(scopeA, created.id, commitFrom(1, 'stale')), 'VERSION_CONFLICT');
|
|
80
|
-
const
|
|
81
|
-
assert(
|
|
82
|
-
const latest = await store.get(scopeA, created.id);
|
|
83
|
-
assert(deepEqual(latest?.content, { text: 'v2' }), 'latest is v2');
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test.it('records history newest first with idempotency lookup', async () => {
|
|
87
|
-
const store = await factory();
|
|
88
|
-
const created = await store.create(scopeA, sample);
|
|
89
|
-
await store.commit(scopeA, created.id, {
|
|
90
|
-
...commitFrom(1, 'x'),
|
|
91
|
-
idempotencyKey: 'call_1',
|
|
92
|
-
message: 'edit',
|
|
93
|
-
});
|
|
94
|
-
const history = await store.history(scopeA, created.id, {});
|
|
95
|
-
assert(history.items[0]?.version === 2 && history.items[1]?.version === 1, 'history order');
|
|
96
|
-
assert(history.items[0]?.message === 'edit', 'message kept');
|
|
97
|
-
const found = await store.findByIdempotencyKey(scopeA, created.id, 'call_1');
|
|
98
|
-
assert(found?.version === 2, 'idempotency key lookup');
|
|
99
|
-
assert((await store.findByIdempotencyKey(scopeA, created.id, 'missing')) === null, 'unknown key');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test.it('filters and paginates lists', async () => {
|
|
103
|
-
const store = await factory();
|
|
104
|
-
for (let i = 0; i < 5; i++)
|
|
105
|
-
await store.create(scopeA, { ...sample, title: `Doc ${i}`, kind: i % 2 ? 'sheet' : 'note' });
|
|
106
|
-
const sheets = await store.list(scopeA, { kind: 'sheet' }, {});
|
|
107
|
-
assert(sheets.items.length === 2, 'kind filter');
|
|
108
|
-
const titled = await store.list(scopeA, { title: 'doc 3' }, {});
|
|
109
|
-
assert(titled.items.length === 1, 'title filter is case-insensitive');
|
|
110
|
-
const page1 = await store.list(scopeA, {}, { limit: 2 });
|
|
111
|
-
assert(page1.items.length === 2 && page1.nextCursor !== undefined, 'first page');
|
|
112
|
-
const page2 = await store.list(scopeA, {}, { limit: 2, cursor: page1.nextCursor });
|
|
113
|
-
assert(
|
|
114
|
-
page2.items.length === 2 && !page2.items.some((item) => page1.items.some((p) => p.id === item.id)),
|
|
115
|
-
'second page',
|
|
116
|
-
);
|
|
90
|
+
const current = await store.get(scopeA, created.id);
|
|
91
|
+
assert(current?.content === 'v2', 'a rejected commit leaves the current content untouched');
|
|
117
92
|
});
|
|
118
93
|
|
|
119
|
-
test.it('
|
|
94
|
+
test.it('filters the list by format and tag', async () => {
|
|
120
95
|
const store = await factory();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
assert(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const entry = (await store.history(scopeA, created.id, {})).items[0];
|
|
128
|
-
const log = entry?.statusLog ?? [];
|
|
129
|
-
assert(log.length === 2, 'status changes are logged on the current version');
|
|
130
|
-
assert(log[1]?.status === 'draft' && log[1]?.message === 'needs a cost table', 'reason is kept');
|
|
96
|
+
await store.create(scopeA, sample);
|
|
97
|
+
await store.create(scopeA, { ...sample, format: 'contract-other', title: 'Other', tags: [] });
|
|
98
|
+
const onlyText = await store.list(scopeA, { format: 'contract-text' }, {});
|
|
99
|
+
assert(onlyText.items.length === 1, 'format filter must narrow the list');
|
|
100
|
+
const tagged = await store.list(scopeA, { tag: 'contract' }, {});
|
|
101
|
+
assert(tagged.items.length === 1, 'tag filter must narrow the list');
|
|
131
102
|
});
|
|
132
103
|
|
|
133
|
-
test.it('locks
|
|
104
|
+
test.it('locks are exclusive to other actors', async () => {
|
|
134
105
|
const store = await factory();
|
|
135
106
|
const created = await store.create(scopeA, sample);
|
|
136
|
-
const lock = await store.lock(scopeA, created.id,
|
|
137
|
-
|
|
138
|
-
await expectCode(store.lock(scopeA, created.id,
|
|
139
|
-
await store.
|
|
140
|
-
|
|
141
|
-
assert((await store.getLock(scopeA, created.id)) === null, 'unlocked');
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test.it('expires locks', async () => {
|
|
145
|
-
const store = await factory();
|
|
146
|
-
const created = await store.create(scopeA, sample);
|
|
147
|
-
await store.lock(scopeA, created.id, agent.actor, 1);
|
|
148
|
-
await new Promise<void>((resolve) => setTimeout(() => resolve(), 15));
|
|
149
|
-
assert((await store.getLock(scopeA, created.id)) === null, 'lock expired');
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test.it('finds artifacts by creation idempotency key when supported', async () => {
|
|
153
|
-
const store = await factory();
|
|
154
|
-
if (!store.findCreatedByIdempotencyKey) return;
|
|
155
|
-
const created = await store.create(scopeA, { ...sample, idempotencyKey: 'create_1' });
|
|
156
|
-
await store.commit(scopeA, created.id, { ...commitFrom(1, 'later'), idempotencyKey: 'apply_1' });
|
|
157
|
-
assert(
|
|
158
|
-
(await store.findCreatedByIdempotencyKey(scopeA, 'create_1'))?.id === created.id,
|
|
159
|
-
'found by create key',
|
|
160
|
-
);
|
|
161
|
-
assert(
|
|
162
|
-
(await store.findCreatedByIdempotencyKey(scopeA, 'apply_1')) === null,
|
|
163
|
-
'apply keys are not create keys',
|
|
164
|
-
);
|
|
165
|
-
assert((await store.findCreatedByIdempotencyKey(scopeB, 'create_1')) === null, 'workspace isolation');
|
|
166
|
-
assert((await store.findCreatedByIdempotencyKey(agent, 'create_1')) === null, 'keys are per actor');
|
|
107
|
+
const lock = await store.lock(scopeA, created.id, scopeA.actor, 60_000);
|
|
108
|
+
const other = { type: 'agent' as const, id: 'agt_1' };
|
|
109
|
+
await expectCode(store.lock(scopeA, created.id, other, 60_000), 'LOCKED');
|
|
110
|
+
await store.unlock(scopeA, lock);
|
|
111
|
+
assert((await store.getLock(scopeA, created.id)) === null, 'unlock must release');
|
|
167
112
|
});
|
|
168
113
|
});
|
|
169
114
|
}
|
|
170
115
|
|
|
171
|
-
|
|
172
|
-
return {
|
|
173
|
-
baseVersion,
|
|
174
|
-
schemaVersion: 1,
|
|
175
|
-
content: { text },
|
|
176
|
-
title: 'Contract test',
|
|
177
|
-
locale: 'en',
|
|
178
|
-
themeId: 'default',
|
|
179
|
-
operations: [{ op: 'replace_content', content: { text } }],
|
|
180
|
-
diff: [],
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export interface KindFixture {
|
|
116
|
+
export interface FormatFixture {
|
|
185
117
|
title: string;
|
|
186
118
|
content: unknown;
|
|
187
|
-
/** Batches
|
|
119
|
+
/** Batches that must apply deterministically on this fixture. */
|
|
188
120
|
batches?: unknown[][];
|
|
189
|
-
/** Batches that must be rejected with the
|
|
121
|
+
/** Batches that must be rejected, with the expected error code. */
|
|
190
122
|
rejects?: { operations: unknown[]; code: string }[];
|
|
191
123
|
}
|
|
192
124
|
|
|
193
125
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* accepts complete content and migrations chain up to the current version.
|
|
126
|
+
* Shared conformance suite every format adapter must pass (spec §8 and §22): examples in the instructions are valid,
|
|
127
|
+
* fixtures validate and read with ids, `apply` is deterministic, pure and atomic, and migrations form a chain.
|
|
197
128
|
*/
|
|
198
|
-
export function
|
|
199
|
-
|
|
200
|
-
fixtures:
|
|
129
|
+
export function defineFormatConformanceTests(
|
|
130
|
+
adapter: AnyFormatAdapter,
|
|
131
|
+
fixtures: FormatFixture[],
|
|
201
132
|
test: TestApi,
|
|
202
133
|
): void {
|
|
203
|
-
const registry = createRegistry({
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
test.describe(`
|
|
207
|
-
test.it('empty() is valid and
|
|
208
|
-
const prepared = prepareContent(registry,
|
|
209
|
-
|
|
134
|
+
const registry = createRegistry({ formats: [adapter] });
|
|
135
|
+
const ctx = { locale: 'en', theme: defaultTheme };
|
|
136
|
+
|
|
137
|
+
test.describe(`Format conformance: ${adapter.format}`, () => {
|
|
138
|
+
test.it('empty() is valid and readable', () => {
|
|
139
|
+
const prepared = prepareContent(registry, adapter.format, adapter.empty());
|
|
140
|
+
const read = adapter.read(prepared.content as never, { mode: 'full', locale: 'en' });
|
|
141
|
+
assert(typeof read.text === 'string', 'read must return text');
|
|
210
142
|
});
|
|
211
143
|
|
|
212
|
-
for (const example of
|
|
213
|
-
test.it(`instruction example "${example.title}" is valid`, () => {
|
|
214
|
-
if (example.input) {
|
|
215
|
-
const input = kind.inputs?.[example.input.format];
|
|
216
|
-
assert(input, `example uses unknown input format "${example.input.format}"`);
|
|
217
|
-
const raw = input.toContent(input.schema.parse(example.input.source), { locale: 'en' });
|
|
218
|
-
const fromInput = prepareContent(registry, kind.kind, raw).content;
|
|
219
|
-
if (example.content !== undefined) {
|
|
220
|
-
const expected = prepareContent(registry, kind.kind, example.content).content;
|
|
221
|
-
assert(deepEqual(fromInput, expected), 'example input must produce the example content');
|
|
222
|
-
}
|
|
223
|
-
}
|
|
144
|
+
for (const example of adapter.instructions.examples) {
|
|
145
|
+
test.it(`instruction example "${example.title}" is valid`, async () => {
|
|
224
146
|
const base =
|
|
225
147
|
example.content === undefined
|
|
226
|
-
?
|
|
227
|
-
: prepareContent(registry,
|
|
148
|
+
? adapter.empty()
|
|
149
|
+
: prepareContent(registry, adapter.format, example.content).content;
|
|
228
150
|
if (example.operations) {
|
|
229
|
-
applyOperations(
|
|
151
|
+
await applyOperations(
|
|
230
152
|
registry,
|
|
231
|
-
{
|
|
153
|
+
{ format: adapter.format, content: base, title: 'Example', themeId: 'default', locale: 'en' },
|
|
232
154
|
example.operations,
|
|
233
155
|
);
|
|
234
156
|
}
|
|
@@ -236,36 +158,36 @@ export function defineKindConformanceTests(
|
|
|
236
158
|
}
|
|
237
159
|
|
|
238
160
|
for (const fixture of fixtures) {
|
|
239
|
-
test.it(`fixture "${fixture.title}": validates,
|
|
240
|
-
const { content } = prepareContent(registry,
|
|
241
|
-
const full =
|
|
242
|
-
const outline =
|
|
243
|
-
assert(full.text.length > 0 && outline.text.length > 0, '
|
|
244
|
-
const someId = [...kind.ids(content)][0];
|
|
245
|
-
if (someId) assert(full.text.includes(someId), `full projection must expose ids (missing ${someId})`);
|
|
161
|
+
test.it(`fixture "${fixture.title}": validates, reads with ids and diffs`, () => {
|
|
162
|
+
const { content } = prepareContent(registry, adapter.format, fixture.content);
|
|
163
|
+
const full = adapter.read(content as never, { mode: 'full', locale: 'en' });
|
|
164
|
+
const outline = adapter.read(content as never, { mode: 'outline', locale: 'en' });
|
|
165
|
+
assert(full.text.length > 0 && outline.text.length > 0, 'reading must not be empty');
|
|
246
166
|
assert(
|
|
247
|
-
|
|
167
|
+
adapter.diff(content as never, structuredClone(content) as never).length === 0,
|
|
248
168
|
'diff of identical content must be empty',
|
|
249
169
|
);
|
|
250
|
-
const partial =
|
|
251
|
-
assert(
|
|
252
|
-
|
|
253
|
-
'parsePartial must accept complete content',
|
|
254
|
-
);
|
|
255
|
-
kind.lint(content, lint);
|
|
256
|
-
assert(deepEqual(JSON.parse(JSON.stringify(content)), content), 'content must be JSON-serializable');
|
|
170
|
+
const partial = adapter.parsePartial(JSON.stringify(content));
|
|
171
|
+
assert(partial !== undefined, 'parsePartial must accept complete content');
|
|
172
|
+
adapter.lint(content as never, ctx);
|
|
257
173
|
});
|
|
258
174
|
|
|
259
175
|
if (fixture.batches?.length) {
|
|
260
|
-
test.it(`fixture "${fixture.title}": apply is deterministic and does not mutate input`, () => {
|
|
261
|
-
let { content } = prepareContent(registry,
|
|
176
|
+
test.it(`fixture "${fixture.title}": apply is deterministic and does not mutate input`, async () => {
|
|
177
|
+
let { content } = prepareContent(registry, adapter.format, fixture.content);
|
|
262
178
|
for (const batch of fixture.batches ?? []) {
|
|
263
179
|
const frozen = structuredClone(content);
|
|
264
|
-
const artifact = {
|
|
265
|
-
|
|
266
|
-
|
|
180
|
+
const artifact = {
|
|
181
|
+
format: adapter.format,
|
|
182
|
+
content,
|
|
183
|
+
title: 'Fixture',
|
|
184
|
+
themeId: 'default',
|
|
185
|
+
locale: 'en',
|
|
186
|
+
};
|
|
187
|
+
const a = await applyOperations(registry, artifact, batch);
|
|
188
|
+
const b = await applyOperations(registry, artifact, batch);
|
|
267
189
|
assert(deepEqual(a.content, b.content), 'same batch must produce the same content');
|
|
268
|
-
assert(deepEqual(a.
|
|
190
|
+
assert(deepEqual(a.createdRefs, b.createdRefs), 'created refs must be deterministic');
|
|
269
191
|
assert(deepEqual(content, frozen), 'apply must not mutate the input');
|
|
270
192
|
assert(a.diff.length > 0, 'a successful batch should report changes');
|
|
271
193
|
content = a.content;
|
|
@@ -274,13 +196,13 @@ export function defineKindConformanceTests(
|
|
|
274
196
|
}
|
|
275
197
|
|
|
276
198
|
for (const [index, rejected] of (fixture.rejects ?? []).entries()) {
|
|
277
|
-
test.it(`fixture "${fixture.title}": rejects batch ${index} with ${rejected.code}`, () => {
|
|
278
|
-
const { content } = prepareContent(registry,
|
|
199
|
+
test.it(`fixture "${fixture.title}": rejects batch ${index} with ${rejected.code}`, async () => {
|
|
200
|
+
const { content } = prepareContent(registry, adapter.format, fixture.content);
|
|
279
201
|
const frozen = structuredClone(content);
|
|
280
202
|
try {
|
|
281
|
-
applyOperations(
|
|
203
|
+
await applyOperations(
|
|
282
204
|
registry,
|
|
283
|
-
{
|
|
205
|
+
{ format: adapter.format, content, title: 'Fixture', themeId: 'default', locale: 'en' },
|
|
284
206
|
rejected.operations,
|
|
285
207
|
);
|
|
286
208
|
} catch (error) {
|
|
@@ -301,12 +223,15 @@ export function defineKindConformanceTests(
|
|
|
301
223
|
}
|
|
302
224
|
}
|
|
303
225
|
|
|
304
|
-
test.it('migrations chain to the current version', () => {
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
);
|
|
226
|
+
test.it('migrations chain to the current format version', () => {
|
|
227
|
+
const chain = adapter.migrations;
|
|
228
|
+
for (const migration of chain) {
|
|
229
|
+
assert(migration.to !== migration.from, 'a migration must change the version');
|
|
230
|
+
}
|
|
231
|
+
assert(adapter.readableVersions.includes(adapter.formatVersion), 'current version must be readable');
|
|
310
232
|
});
|
|
311
233
|
});
|
|
312
234
|
}
|
|
235
|
+
|
|
236
|
+
/** Re-export for adapters that want the generic type in their own tests. */
|
|
237
|
+
export type { FormatAdapter };
|
package/src/theme.ts
CHANGED
package/src/ui.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LintIssue } from './
|
|
1
|
+
import type { LintIssue } from './adapter.js';
|
|
2
2
|
import { COLOR_TOKENS, mixHex, safeFontFamily, type Theme, themeBrand, themeShape } from './theme.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -29,6 +29,22 @@ export interface EmitOptions {
|
|
|
29
29
|
* into kind operations through `emit`, never writing whole content (spec §13.6). Batching, optimistic
|
|
30
30
|
* apply, undo/redo, conflicts and locks are handled by the host (`@artkit-ai/react`).
|
|
31
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* Draft actions the host gives every editor: undo, redo and save. The host draws them unless the editor declares that it
|
|
34
|
+
* draws its own toolbar (see `EditorModule.toolbar` in `@artkit-ai/react`).
|
|
35
|
+
*/
|
|
36
|
+
export interface EditorActions {
|
|
37
|
+
canUndo: boolean;
|
|
38
|
+
canRedo: boolean;
|
|
39
|
+
undo(): void;
|
|
40
|
+
redo(): void;
|
|
41
|
+
/** The draft differs from the last saved version. */
|
|
42
|
+
dirty: boolean;
|
|
43
|
+
saving: boolean;
|
|
44
|
+
canSave: boolean;
|
|
45
|
+
save(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
export interface EditorBindings<Content = unknown, Operation = unknown> {
|
|
33
49
|
content: Content;
|
|
34
50
|
ctx: RenderContext;
|
|
@@ -36,6 +52,8 @@ export interface EditorBindings<Content = unknown, Operation = unknown> {
|
|
|
36
52
|
readOnly: boolean;
|
|
37
53
|
/** Lint issues of the current version, for inline indicators. */
|
|
38
54
|
lint?: LintIssue[];
|
|
55
|
+
/** Undo, redo and save of the draft. */
|
|
56
|
+
actions?: EditorActions;
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
/** Default CSS class prefix for every artkit component (spec §13.5). */
|
|
@@ -64,9 +82,11 @@ export function themeCssVariables(theme: Theme): Record<string, string> {
|
|
|
64
82
|
const shape = themeShape(theme);
|
|
65
83
|
const fonts = themeFontStacks(theme);
|
|
66
84
|
for (const token of COLOR_TOKENS) vars[`--ak-color-${token}`] = colors[token];
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
vars['--ak-color-border
|
|
85
|
+
// Hairlines of the Mouts design system: every card, panel and control is outlined by a 1px line of the text color
|
|
86
|
+
// (border-hairline); dividers inside a surface use the same line at 40% (border-hairline-soft).
|
|
87
|
+
vars['--ak-color-border'] = colors.text;
|
|
88
|
+
vars['--ak-color-border-strong'] = colors.text;
|
|
89
|
+
vars['--ak-color-border-soft'] = mixHex(colors.background, colors.text, 0.4);
|
|
70
90
|
vars['--ak-color-hover'] = mixHex(colors.background, colors.text, 0.06);
|
|
71
91
|
vars['--ak-color-active'] = mixHex(colors.background, colors.text, 0.1);
|
|
72
92
|
vars['--ak-color-selected'] = mixHex(colors.background, brand.primary, 0.12);
|