@ordinatio/entities 1.1.0 → 1.3.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.
@@ -0,0 +1,1043 @@
1
+ import {
2
+ DEFAULT_TAG_LIMITS,
3
+ checkTagName,
4
+ resolveTagColor
5
+ } from "../../chunk-KPZRBM5J.mjs";
6
+
7
+ // src/tags/server/plan.ts
8
+ function planTagChanges(change, maxChanges) {
9
+ const add = [...new Set(change.add)];
10
+ const adding = new Set(add);
11
+ const remove = [...new Set(change.remove)].filter((id) => !adding.has(id));
12
+ const count2 = add.length + remove.length;
13
+ if (count2 > maxChanges) return { ok: false, reason: "too-many-changes", count: count2 };
14
+ return { ok: true, change: { add, remove } };
15
+ }
16
+
17
+ // src/tags/server/service.ts
18
+ function byName(a, b) {
19
+ return a.name.localeCompare(b.name) || a.id.localeCompare(b.id);
20
+ }
21
+ function unknownKind(method, result) {
22
+ throw new Error(`tag store ${method} returned an unknown result kind "${result.kind}": the store and the service are out of step`);
23
+ }
24
+ function createTagService(store, suppliedLimits = DEFAULT_TAG_LIMITS) {
25
+ const limits = { ...suppliedLimits };
26
+ function fail(failure) {
27
+ return { ok: false, ...failure };
28
+ }
29
+ function checkName(name) {
30
+ const checked = checkTagName(name, limits.maxNameLength);
31
+ if (checked.ok) return checked;
32
+ return { ok: false, failure: { reason: "invalid-name", problem: checked.reason, length: checked.length } };
33
+ }
34
+ async function create(input) {
35
+ const name = checkName(input.name);
36
+ if (!name.ok) return fail(name.failure);
37
+ const color = resolveTagColor(input.color);
38
+ if (!color.ok) return fail({ reason: "invalid-color", color: String(input.color) });
39
+ const result = await store.create({ name: name.name, nameKey: name.key, color: color.color }, { maxTags: limits.maxPerWorkspace });
40
+ if (result.kind === "created") return { ok: true, tag: result.tag };
41
+ if (result.kind === "duplicate") return fail({ reason: "duplicate" });
42
+ if (result.kind === "limit") return fail({ reason: "limit" });
43
+ return unknownKind("create", result);
44
+ }
45
+ async function findOrCreate(name) {
46
+ const checked = checkName(name);
47
+ if (!checked.ok) return fail(checked.failure);
48
+ const existing = await store.findByKey(checked.key);
49
+ if (existing) return { ok: true, tag: existing, created: false };
50
+ for (let attempt = 0; attempt < 2; attempt++) {
51
+ const made = await create({ name: checked.name });
52
+ if (made.ok) return { ok: true, tag: made.tag, created: true };
53
+ if (made.reason !== "duplicate") return made;
54
+ const raced = await store.findByKey(checked.key);
55
+ if (raced) return { ok: true, tag: raced, created: false };
56
+ }
57
+ return fail({ reason: "duplicate" });
58
+ }
59
+ async function update(id, input) {
60
+ if (!await store.get(id)) return fail({ reason: "not-found" });
61
+ const patch = {};
62
+ if (input.name !== void 0) {
63
+ const name = checkName(input.name);
64
+ if (!name.ok) return fail(name.failure);
65
+ patch.rename = { name: name.name, nameKey: name.key };
66
+ }
67
+ if (input.color !== void 0) {
68
+ const color = resolveTagColor(input.color);
69
+ if (!color.ok) return fail({ reason: "invalid-color", color: String(input.color) });
70
+ patch.color = color.color;
71
+ }
72
+ if (input.pinned !== void 0) patch.pinned = input.pinned;
73
+ const result = await store.update(id, patch, { maxPinned: limits.maxPinned });
74
+ if (result.kind === "updated") return { ok: true, before: result.before, after: result.after };
75
+ if (result.kind === "notFound") return fail({ reason: "not-found" });
76
+ if (result.kind === "pinLimit") return fail({ reason: "pin-limit" });
77
+ if (result.kind === "duplicate") return fail({ reason: "duplicate" });
78
+ return unknownKind("update", result);
79
+ }
80
+ return {
81
+ async list() {
82
+ return [...await store.list()].sort(byName);
83
+ },
84
+ async options() {
85
+ return [...await store.list()].sort(byName).map((tag) => ({ id: tag.id, name: tag.name, color: tag.color }));
86
+ },
87
+ async pinned() {
88
+ return [...await store.listPinned(limits.maxPinned)].sort(byName);
89
+ },
90
+ async get(id) {
91
+ const tag = await store.get(id);
92
+ return tag ? { ok: true, tag } : fail({ reason: "not-found" });
93
+ },
94
+ create,
95
+ findOrCreate,
96
+ update,
97
+ async remove(id) {
98
+ const result = await store.remove(id);
99
+ if (result.kind === "removed") return { ok: true, tag: result.tag };
100
+ if (result.kind === "notFound") return fail({ reason: "not-found" });
101
+ return unknownKind("remove", result);
102
+ },
103
+ async applyChanges(target, targetId, change, actorId) {
104
+ const plan = planTagChanges(change, limits.maxChangesPerSave);
105
+ if (!plan.ok) return fail({ reason: "too-many-changes", count: plan.count });
106
+ if (plan.change.add.length === 0 && plan.change.remove.length === 0) return { ok: true, added: [], removed: [], skipped: [] };
107
+ if (!await store.targetExists(target, targetId)) return fail({ reason: "target-gone" });
108
+ const result = await store.applyLinks(target, targetId, plan.change, actorId);
109
+ if (result.kind === "applied") return { ok: true, added: result.added, removed: result.removed, skipped: result.skipped };
110
+ if (result.kind === "targetGone") return fail({ reason: "target-gone" });
111
+ if (result.kind === "tagGone") return fail({ reason: "tag-gone" });
112
+ return unknownKind("applyLinks", result);
113
+ },
114
+ async forTarget(target, targetId) {
115
+ if (!await store.targetExists(target, targetId)) return fail({ reason: "target-gone" });
116
+ return { ok: true, tags: [...await store.tagsForTarget(target, targetId)].sort(byName) };
117
+ },
118
+ async forTargets(target, targetIds) {
119
+ const found = await store.tagsForTargets(target, targetIds);
120
+ return new Map([...found].map(([id, tags]) => [id, [...tags].sort(byName)]));
121
+ },
122
+ counts(ids2) {
123
+ return store.counts(ids2);
124
+ }
125
+ };
126
+ }
127
+
128
+ // src/tags/server/memory-store.ts
129
+ var tick = () => Promise.resolve();
130
+ var linkKey = (type, targetId, tagId) => `${type}\0${targetId}\0${tagId}`;
131
+ var targetKey = (type, id) => `${type}\0${id}`;
132
+ function createMemoryTagWorld(options) {
133
+ const serialise = options.serialise ?? true;
134
+ const tags = /* @__PURE__ */ new Map();
135
+ const links = /* @__PURE__ */ new Map();
136
+ const targets = /* @__PURE__ */ new Map();
137
+ let sequence = 0;
138
+ let pendingHook = null;
139
+ let tail = Promise.resolve();
140
+ function exclusive(fn) {
141
+ if (!serialise) return fn();
142
+ const run = tail.then(fn, fn);
143
+ tail = run.then(() => void 0, () => void 0);
144
+ return run;
145
+ }
146
+ const copy = (record) => ({ ...record });
147
+ function store(workspace) {
148
+ const mine = () => [...tags.values()].filter((row) => row.workspace === workspace).map((row) => row.record);
149
+ const own = (id) => {
150
+ const row = tags.get(id);
151
+ return row && row.workspace === workspace ? row.record : void 0;
152
+ };
153
+ const targetIsMine = (type, id) => targets.get(targetKey(type, id))?.workspace === workspace;
154
+ const zeroCounts = () => Object.fromEntries(options.targetTypes.map((type) => [type, 0]));
155
+ return {
156
+ async list() {
157
+ return mine().map(copy);
158
+ },
159
+ async listPinned(limit) {
160
+ return mine().filter((tag) => tag.pinned).slice(0, limit).map(copy);
161
+ },
162
+ async getMany(ids2) {
163
+ const wanted = new Set(ids2);
164
+ return mine().filter((tag) => wanted.has(tag.id)).map(copy);
165
+ },
166
+ async findByKey(nameKey) {
167
+ const found = mine().find((tag) => tag.nameKey === nameKey);
168
+ return found ? copy(found) : null;
169
+ },
170
+ async get(id) {
171
+ const found = own(id);
172
+ return found ? copy(found) : null;
173
+ },
174
+ create(input, limits) {
175
+ return exclusive(async () => {
176
+ if (mine().length >= limits.maxTags) return { kind: "limit" };
177
+ if (mine().some((tag) => tag.nameKey === input.nameKey)) return { kind: "duplicate" };
178
+ await tick();
179
+ const record = { id: `tag_${++sequence}`, name: input.name, nameKey: input.nameKey, color: input.color, pinned: false };
180
+ tags.set(record.id, { workspace, record });
181
+ return { kind: "created", tag: copy(record) };
182
+ });
183
+ },
184
+ update(id, patch, limits) {
185
+ return exclusive(async () => {
186
+ const current = own(id);
187
+ if (!current) return { kind: "notFound" };
188
+ const before = copy(current);
189
+ if (patch.pinned === true && !current.pinned && mine().filter((tag) => tag.pinned).length >= limits.maxPinned) return { kind: "pinLimit" };
190
+ if (patch.rename && mine().some((tag) => tag.id !== id && tag.nameKey === patch.rename?.nameKey)) return { kind: "duplicate" };
191
+ await tick();
192
+ if (patch.rename) {
193
+ current.name = patch.rename.name;
194
+ current.nameKey = patch.rename.nameKey;
195
+ }
196
+ if (patch.color !== void 0) current.color = patch.color;
197
+ if (patch.pinned !== void 0) current.pinned = patch.pinned;
198
+ return { kind: "updated", before, after: copy(current) };
199
+ });
200
+ },
201
+ remove(id) {
202
+ return exclusive(async () => {
203
+ const current = own(id);
204
+ if (!current) return { kind: "notFound" };
205
+ await tick();
206
+ tags.delete(id);
207
+ for (const [key3, link] of links) if (link.tagId === id) links.delete(key3);
208
+ return { kind: "removed", tag: copy(current) };
209
+ });
210
+ },
211
+ async targetExists(target, targetId) {
212
+ return targetIsMine(target, targetId);
213
+ },
214
+ async applyLinks(target, targetId, change, actorId) {
215
+ const first = await exclusive(async () => {
216
+ if (!targetIsMine(target, targetId)) return null;
217
+ const wanted = [...new Set(change.add)];
218
+ return { attachable: wanted.filter((id) => own(id)), skipped: wanted.filter((id) => !own(id)) };
219
+ });
220
+ if (!first) return { kind: "targetGone" };
221
+ const hook = pendingHook;
222
+ pendingHook = null;
223
+ if (hook) await hook();
224
+ return exclusive(async () => {
225
+ if (!targetIsMine(target, targetId)) return { kind: "targetGone" };
226
+ if (first.attachable.some((id) => !own(id))) return { kind: "tagGone" };
227
+ await tick();
228
+ const added = [];
229
+ for (const tagId of first.attachable) {
230
+ const key3 = linkKey(target, targetId, tagId);
231
+ if (links.has(key3)) continue;
232
+ await tick();
233
+ links.set(key3, { workspace, type: target, targetId, tagId, actorId });
234
+ added.push(tagId);
235
+ }
236
+ const removed = [];
237
+ for (const tagId of new Set(change.remove)) {
238
+ if (!own(tagId)) continue;
239
+ const existing = linkKey(target, targetId, tagId);
240
+ if (!links.has(existing)) continue;
241
+ await tick();
242
+ links.delete(existing);
243
+ removed.push(tagId);
244
+ }
245
+ return { kind: "applied", added, removed, skipped: first.skipped };
246
+ });
247
+ },
248
+ async tagsForTarget(target, targetId) {
249
+ if (!targetIsMine(target, targetId)) return [];
250
+ return [...links.values()].filter((link) => link.type === target && link.targetId === targetId).map((link) => own(link.tagId)).filter((tag) => Boolean(tag)).map(copy);
251
+ },
252
+ async tagsForTargets(target, targetIds) {
253
+ const wanted = new Set(targetIds.filter((id) => targetIsMine(target, id)));
254
+ const result = /* @__PURE__ */ new Map();
255
+ for (const link of links.values()) {
256
+ if (link.type !== target || !wanted.has(link.targetId)) continue;
257
+ const tag = own(link.tagId);
258
+ if (!tag) continue;
259
+ const list = result.get(link.targetId) ?? [];
260
+ list.push(copy(tag));
261
+ result.set(link.targetId, list);
262
+ }
263
+ return result;
264
+ },
265
+ async counts(ids2) {
266
+ const only = ids2 ? new Set(ids2) : null;
267
+ const result = /* @__PURE__ */ new Map();
268
+ for (const tag of mine()) if (!only || only.has(tag.id)) result.set(tag.id, zeroCounts());
269
+ for (const link of links.values()) {
270
+ if (link.workspace !== workspace) continue;
271
+ const entry = result.get(link.tagId);
272
+ if (entry && link.type in entry) entry[link.type] += 1;
273
+ }
274
+ return result;
275
+ }
276
+ };
277
+ }
278
+ return {
279
+ store,
280
+ addTarget(workspace, type, chosenId) {
281
+ const id = chosenId ?? `${type}_${++sequence}`;
282
+ targets.set(targetKey(type, id), { workspace });
283
+ return id;
284
+ },
285
+ removeTarget(_workspace, type, id) {
286
+ return exclusive(async () => {
287
+ targets.delete(targetKey(type, id));
288
+ for (const [key3, link] of links) if (link.type === type && link.targetId === id) links.delete(key3);
289
+ });
290
+ },
291
+ armBeforeLinkWrite(hook) {
292
+ pendingHook = hook;
293
+ },
294
+ actorOf(workspace, type, targetId, tagId) {
295
+ const link = links.get(linkKey(type, targetId, tagId));
296
+ return link && link.workspace === workspace ? link.actorId : void 0;
297
+ }
298
+ };
299
+ }
300
+ function createMemoryTagHarness(targetTypes, options = {}) {
301
+ const world = createMemoryTagWorld({ targetTypes, serialise: options.serialise });
302
+ let workspaces = 0;
303
+ return {
304
+ world,
305
+ targetTypes,
306
+ async newWorkspace() {
307
+ return `workspace_${++workspaces}`;
308
+ },
309
+ storeFor: (workspace) => world.store(workspace),
310
+ async addTarget(workspace, type) {
311
+ return world.addTarget(workspace, type);
312
+ },
313
+ async addTargetWithId(workspace, type, id) {
314
+ return world.addTarget(workspace, type, id);
315
+ },
316
+ async removeTarget(workspace, type, id) {
317
+ await world.removeTarget(workspace, type, id);
318
+ },
319
+ armBeforeLinkWrite: (hook) => world.armBeforeLinkWrite(hook),
320
+ async cleanup() {
321
+ }
322
+ };
323
+ }
324
+
325
+ // src/tags/server/contract-kit.ts
326
+ var LIMITS = { maxTags: 100 };
327
+ var PIN = { maxPinned: 100 };
328
+ var key = (name) => name.toLowerCase();
329
+ var ids = (tags) => tags.map((tag) => tag.id).sort();
330
+ var core = (tag) => ({ id: tag.id, name: tag.name, nameKey: tag.nameKey, color: tag.color, pinned: tag.pinned });
331
+ var cores = (tags) => tags.map(core).sort((x, y) => x.id.localeCompare(y.id));
332
+ function makeKit(api, harness) {
333
+ if (harness.targetTypes.length < 3) throw new Error("the tag store contract needs at least three target types");
334
+ const [first, second, third] = harness.targetTypes;
335
+ async function setup() {
336
+ const workspace = await harness.newWorkspace();
337
+ const store = harness.storeFor(workspace);
338
+ const make = async (s, name, color = "neutral") => {
339
+ const result = await s.create({ name, nameKey: key(name), color }, LIMITS);
340
+ if (result.kind !== "created") throw new Error(`setup: could not create ${name}: ${result.kind}`);
341
+ return result.tag;
342
+ };
343
+ return { workspace, store, make };
344
+ }
345
+ return { api, harness, first, second, third, setup };
346
+ }
347
+
348
+ // src/tags/server/contract-tags.ts
349
+ function defineTagStoreTagsContract(kit) {
350
+ const { describe, it, expect } = kit.api;
351
+ const { harness, first, second, setup } = kit;
352
+ describe("TagStore contract: tags", () => {
353
+ it("creates a tag and finds it by id, by key and in the list; getMany returns only this workspace's tags", async () => {
354
+ const { store, make } = await setup();
355
+ const vip = await make(store, "VIP", "red");
356
+ expect([vip.name, vip.nameKey, vip.color, vip.pinned]).toEqual(["VIP", "vip", "red", false]);
357
+ expect((await store.get(vip.id))?.id).toBe(vip.id);
358
+ expect((await store.findByKey("vip"))?.id).toBe(vip.id);
359
+ expect(await store.findByKey("nope")).toBeNull();
360
+ expect(ids(await store.list())).toEqual([vip.id]);
361
+ expect(cores(await store.list())).toEqual(cores([vip]));
362
+ expect(cores(await store.getMany([vip.id, "missing"]))).toEqual(cores([vip]));
363
+ expect(cores([await store.findByKey("vip")])).toEqual(cores([vip]));
364
+ expect(ids(await store.getMany([vip.id, "missing"]))).toEqual([vip.id]);
365
+ const other = await make(store, "Other");
366
+ const unasked = await make(store, "Unasked");
367
+ expect(ids(await store.getMany([vip.id, other.id, "missing"]))).toEqual(ids([vip, other]));
368
+ expect(await store.getMany([])).toEqual([]);
369
+ expect(ids(await store.getMany([vip.id, vip.id]))).toEqual([vip.id]);
370
+ expect(ids(await store.getMany([unasked.id]))).toEqual([unasked.id]);
371
+ });
372
+ it("the key is whatever the caller supplied, kept apart from the name through create, rename and remove, and a renamed-away key is free again", async () => {
373
+ const { store } = await setup();
374
+ const created = await store.create({ name: "Foo Bar", nameKey: "foo-bar", color: "neutral" }, LIMITS);
375
+ if (created.kind !== "created") throw new Error("setup");
376
+ expect(created.tag.nameKey).toBe("foo-bar");
377
+ expect((await store.findByKey("foo-bar"))?.id).toBe(created.tag.id);
378
+ expect(await store.findByKey("foo bar")).toBeNull();
379
+ const renamed = await store.update(created.tag.id, { rename: { name: "Baz Qux", nameKey: "baz-qux" } }, PIN);
380
+ expect(renamed.kind === "updated" && [renamed.before.nameKey, renamed.after.nameKey]).toEqual(["foo-bar", "baz-qux"]);
381
+ expect(await store.findByKey("foo-bar")).toBeNull();
382
+ expect((await store.create({ name: "Foo Bar", nameKey: "foo-bar", color: "neutral" }, LIMITS)).kind).toBe("created");
383
+ const removed = await store.remove(created.tag.id);
384
+ expect(removed.kind === "removed" && [removed.tag.name, removed.tag.nameKey]).toEqual(["Baz Qux", "baz-qux"]);
385
+ });
386
+ it("a second tag with the same key is a duplicate and changes nothing", async () => {
387
+ const { store, make } = await setup();
388
+ await make(store, "VIP");
389
+ expect(await store.create({ name: "vip", nameKey: "vip", color: "blue" }, LIMITS)).toEqual({ kind: "duplicate" });
390
+ expect((await store.list()).length).toBe(1);
391
+ });
392
+ it("the create limit wins over duplicate, and only this workspace's tags count toward it", async () => {
393
+ const { store, make } = await setup();
394
+ await make(store, "a");
395
+ await make(store, "b");
396
+ expect(await store.create({ name: "a", nameKey: "a", color: "neutral" }, { maxTags: 2 })).toEqual({ kind: "limit" });
397
+ expect(await store.create({ name: "new", nameKey: "new", color: "neutral" }, { maxTags: 0 })).toEqual({ kind: "limit" });
398
+ const pinned = await make(store, "pinned");
399
+ await store.update(pinned.id, { pinned: true }, PIN);
400
+ expect(await store.create({ name: "new", nameKey: "new", color: "neutral" }, { maxTags: 3 })).toEqual({ kind: "limit" });
401
+ const other = harness.storeFor(await harness.newWorkspace());
402
+ await make(other, "x");
403
+ await make(other, "y");
404
+ expect((await other.create({ name: "z", nameKey: "z", color: "neutral" }, { maxTags: 3 })).kind).toBe("created");
405
+ });
406
+ it("rename: a case change of the same tag works, onto another tag's key is a duplicate, and sequential renames give consistent pairs", async () => {
407
+ const { store, make } = await setup();
408
+ const a = await make(store, "vip");
409
+ const b = await make(store, "other");
410
+ const cased = await store.update(a.id, { rename: { name: "VIP", nameKey: "vip" } }, PIN);
411
+ expect(cased.kind === "updated" && [cased.before.name, cased.after.name]).toEqual(["vip", "VIP"]);
412
+ expect(await store.update(a.id, { rename: { name: "Other", nameKey: "other" }, color: "red", pinned: true }, PIN)).toEqual({ kind: "duplicate" });
413
+ expect(core(await store.get(a.id))).toEqual({ ...core(a), name: "VIP" });
414
+ expect(await store.update(a.id, { rename: { name: "Other", nameKey: "other" } }, PIN)).toEqual({ kind: "duplicate" });
415
+ const toB = await store.update(b.id, { rename: { name: "B", nameKey: "b" } }, PIN);
416
+ const toC = await store.update(b.id, { rename: { name: "C", nameKey: "c" } }, PIN);
417
+ expect(toB.kind === "updated" && toC.kind === "updated" && [toB.after.name, toC.before.name, toC.after.name]).toEqual(["B", "B", "C"]);
418
+ });
419
+ it("an empty update is a no-op that reports equal before and after", async () => {
420
+ const { store, make } = await setup();
421
+ const tag = await make(store, "x", "red");
422
+ const result = await store.update(tag.id, {}, PIN);
423
+ expect(result.kind === "updated" && [core(result.before), core(result.after)]).toEqual([core(tag), core(tag)]);
424
+ });
425
+ it("update changes colour and pinned together and reports before and after", async () => {
426
+ const { store, make } = await setup();
427
+ const tag = await make(store, "x");
428
+ const result = await store.update(tag.id, { color: "blue", pinned: true }, PIN);
429
+ expect(result.kind === "updated" && [result.before.color, result.before.pinned, result.after.color, result.after.pinned]).toEqual(["neutral", false, "blue", true]);
430
+ });
431
+ it("one update can rename, recolour and pin together, and every part is applied", async () => {
432
+ const { store, make } = await setup();
433
+ const tag = await make(store, "old");
434
+ const result = await store.update(tag.id, { rename: { name: "New", nameKey: "new" }, color: "blue", pinned: true }, PIN);
435
+ expect(result.kind === "updated" && [result.after.name, result.after.nameKey, result.after.color, result.after.pinned]).toEqual(["New", "new", "blue", true]);
436
+ expect(result.kind === "updated" && [result.before.name, result.before.color, result.before.pinned]).toEqual(["old", "neutral", false]);
437
+ expect((await store.findByKey("new"))?.id).toBe(tag.id);
438
+ });
439
+ it("a single-field update leaves the other fields as they were", async () => {
440
+ const { store, make } = await setup();
441
+ const tag = await make(store, "base", "red");
442
+ await store.update(tag.id, { pinned: true }, PIN);
443
+ const renamed = await store.update(tag.id, { rename: { name: "Renamed", nameKey: "renamed" } }, PIN);
444
+ expect(renamed.kind === "updated" && [renamed.after.color, renamed.after.pinned]).toEqual(["red", true]);
445
+ const recoloured = await store.update(tag.id, { color: "blue" }, PIN);
446
+ expect(recoloured.kind === "updated" && [recoloured.after.name, recoloured.after.pinned]).toEqual(["Renamed", true]);
447
+ const unpinned = await store.update(tag.id, { pinned: false }, PIN);
448
+ expect(unpinned.kind === "updated" && [unpinned.after.name, unpinned.after.color]).toEqual(["Renamed", "blue"]);
449
+ });
450
+ it("unpinning frees the pin slot for another tag", async () => {
451
+ const { store, make } = await setup();
452
+ const [a, b] = [await make(store, "a"), await make(store, "b")];
453
+ await store.update(a.id, { pinned: true }, { maxPinned: 1 });
454
+ expect((await store.update(b.id, { pinned: true }, { maxPinned: 1 })).kind).toBe("pinLimit");
455
+ await store.update(a.id, { pinned: false }, { maxPinned: 1 });
456
+ expect((await store.update(b.id, { pinned: true }, { maxPinned: 1 })).kind).toBe("updated");
457
+ });
458
+ it("pinning: pin limit wins over duplicate; an already-pinned tag re-pins at the limit; unpinning needs no limit", async () => {
459
+ const { store, make } = await setup();
460
+ const a = await make(store, "a");
461
+ const b = await make(store, "b");
462
+ expect((await store.update(a.id, { pinned: true }, { maxPinned: 1 })).kind).toBe("updated");
463
+ expect(await store.update(b.id, { rename: { name: "A", nameKey: "a" }, color: "red", pinned: true }, { maxPinned: 1 })).toEqual({ kind: "pinLimit" });
464
+ expect(core(await store.get(b.id))).toEqual(core(b));
465
+ expect((await store.update(a.id, { pinned: true }, { maxPinned: 1 })).kind).toBe("updated");
466
+ const unpinned = await store.update(a.id, { pinned: false }, { maxPinned: 0 });
467
+ expect(unpinned.kind === "updated" && [unpinned.before.pinned, unpinned.after.pinned]).toEqual([true, false]);
468
+ expect((await store.get(a.id))?.pinned).toBe(false);
469
+ expect((await store.update(b.id, { pinned: false }, { maxPinned: 0 })).kind).toBe("updated");
470
+ expect((await store.get(b.id))?.name).toBe("b");
471
+ });
472
+ it("update and remove of an unknown id are notFound, and listPinned returns only pinned tags up to the limit", async () => {
473
+ const { store, make } = await setup();
474
+ expect(await store.update("missing", { color: "red" }, PIN)).toEqual({ kind: "notFound" });
475
+ expect(await store.remove("missing")).toEqual({ kind: "notFound" });
476
+ const tags = [await make(store, "a"), await make(store, "b"), await make(store, "c"), await make(store, "d")];
477
+ for (const tag of tags.slice(1)) await store.update(tag.id, { pinned: true }, PIN);
478
+ expect((await store.listPinned(2)).length).toBe(2);
479
+ expect((await store.listPinned(0)).length).toBe(0);
480
+ expect(ids(await store.listPinned(10))).toEqual(ids(tags.slice(1)));
481
+ expect(cores(await store.listPinned(10))).toEqual(cores(tags.slice(1)).map((tag) => ({ ...tag, pinned: true })));
482
+ });
483
+ it("remove returns the tag, the tag disappears from its targets and from the counts, and a second remove is notFound", async () => {
484
+ const { workspace, store, make } = await setup();
485
+ const made = await make(store, "gone", "red");
486
+ await store.update(made.id, { pinned: true }, PIN);
487
+ const tag = await store.get(made.id);
488
+ const target = await harness.addTarget(workspace, first);
489
+ await store.applyLinks(first, target, { add: [tag.id], remove: [] }, "actor");
490
+ const removed = await store.remove(tag.id);
491
+ expect(removed.kind === "removed" && core(removed.tag)).toEqual(core(tag));
492
+ expect((await store.create({ name: "gone", nameKey: "gone", color: "red" }, { maxTags: 1 })).kind).toBe("created");
493
+ expect((await store.tagsForTarget(first, target)).length).toBe(0);
494
+ expect((await store.counts()).has(tag.id)).toBe(false);
495
+ expect(await store.remove(tag.id)).toEqual({ kind: "notFound" });
496
+ });
497
+ });
498
+ }
499
+
500
+ // src/tags/server/contract-links.ts
501
+ function defineTagStoreLinksContract(kit) {
502
+ const { describe, it, expect } = kit.api;
503
+ const { harness, first, second, setup } = kit;
504
+ describe("TagStore contract: links", () => {
505
+ it("adds and removes links; repeating an add or a remove reports nothing the second time", async () => {
506
+ const { workspace, store, make } = await setup();
507
+ const [a, b] = [await make(store, "a"), await make(store, "b")];
508
+ const target = await harness.addTarget(workspace, first);
509
+ const added = await store.applyLinks(first, target, { add: [a.id, b.id], remove: [] }, "u1");
510
+ expect(added.kind === "applied" && [[...added.added].sort(), added.removed, added.skipped]).toEqual([[a.id, b.id].sort(), [], []]);
511
+ const again = await store.applyLinks(first, target, { add: [a.id], remove: [] }, "u2");
512
+ expect(again.kind === "applied" && [again.added, again.skipped]).toEqual([[], []]);
513
+ const removed = await store.applyLinks(first, target, { add: [], remove: [a.id] }, "u1");
514
+ expect(removed.kind === "applied" && removed.removed).toEqual([a.id]);
515
+ const removedAgain = await store.applyLinks(first, target, { add: [], remove: [a.id] }, "u1");
516
+ expect(removedAgain.kind === "applied" && removedAgain.removed).toEqual([]);
517
+ expect(ids(await store.tagsForTarget(first, target))).toEqual([b.id]);
518
+ });
519
+ it("removing a tag from one target leaves the same tag on another target of the same kind", async () => {
520
+ const { workspace, store, make } = await setup();
521
+ const a = await make(store, "a");
522
+ const [one, two] = [await harness.addTarget(workspace, first), await harness.addTarget(workspace, first)];
523
+ await store.applyLinks(first, one, { add: [a.id], remove: [] }, "u");
524
+ await store.applyLinks(first, two, { add: [a.id], remove: [] }, "u");
525
+ await store.applyLinks(first, two, { add: [], remove: [a.id] }, "u");
526
+ expect(ids(await store.tagsForTarget(first, two))).toEqual([]);
527
+ expect(ids(await store.tagsForTarget(first, one))).toEqual([a.id]);
528
+ expect((await store.counts([a.id])).get(a.id)?.[first]).toBe(1);
529
+ });
530
+ it("counts for several ids returns exactly the live ones asked for, each with its own numbers", async () => {
531
+ const { workspace, store, make } = await setup();
532
+ const [a, b, doomed] = [await make(store, "a"), await make(store, "b"), await make(store, "doomed")];
533
+ const [t1, t2] = [await harness.addTarget(workspace, first), await harness.addTarget(workspace, first)];
534
+ await store.applyLinks(first, t1, { add: [a.id, b.id], remove: [] }, "u");
535
+ await store.applyLinks(first, t2, { add: [a.id], remove: [] }, "u");
536
+ await store.remove(doomed.id);
537
+ const other = harness.storeFor(await harness.newWorkspace());
538
+ const foreign = await make(other, "foreign");
539
+ const counts = await store.counts([a.id, b.id, doomed.id, foreign.id, "missing"]);
540
+ expect([...counts.keys()].sort()).toEqual([a.id, b.id].sort());
541
+ expect([counts.get(a.id)?.[first], counts.get(b.id)?.[first]]).toEqual([2, 1]);
542
+ });
543
+ it("several skipped and several removed ids in one call are all reported, and only the ones really linked count as removed", async () => {
544
+ const { workspace, store, make } = await setup();
545
+ const [a, b, never] = [await make(store, "a"), await make(store, "b"), await make(store, "never")];
546
+ const target = await harness.addTarget(workspace, first);
547
+ const added = await store.applyLinks(first, target, { add: [a.id, b.id, "ghost-1", "ghost-2"], remove: [] }, "u");
548
+ expect(added.kind === "applied" && [[...added.added].sort(), [...added.skipped].sort()]).toEqual([[a.id, b.id].sort(), ["ghost-1", "ghost-2"]]);
549
+ const removed = await store.applyLinks(first, target, { add: [], remove: [a.id, never.id, b.id] }, "u");
550
+ expect(removed.kind === "applied" && [...removed.removed].sort()).toEqual([a.id, b.id].sort());
551
+ expect(ids(await store.tagsForTarget(first, target))).toEqual([]);
552
+ });
553
+ it("a removed tag is gone from every read, frees its pin slot and its key, and cannot be linked again", async () => {
554
+ const { workspace, store, make } = await setup();
555
+ const [gone, keeper] = [await make(store, "gone", "red"), await make(store, "keeper")];
556
+ await store.update(gone.id, { pinned: true }, PIN);
557
+ const target = await harness.addTarget(workspace, first);
558
+ await store.applyLinks(first, target, { add: [gone.id], remove: [] }, "u");
559
+ expect((await store.remove(gone.id)).kind).toBe("removed");
560
+ expect(ids(await store.list())).toEqual([keeper.id]);
561
+ expect(await store.listPinned(10)).toEqual([]);
562
+ expect(await store.getMany([gone.id])).toEqual([]);
563
+ expect(await store.get(gone.id)).toBeNull();
564
+ expect(await store.findByKey(gone.nameKey)).toBeNull();
565
+ expect(await store.update(gone.id, { color: "blue" }, PIN)).toEqual({ kind: "notFound" });
566
+ expect((await store.tagsForTargets(first, [target])).size).toBe(0);
567
+ expect((await store.update(keeper.id, { pinned: true }, { maxPinned: 1 })).kind).toBe("updated");
568
+ expect((await store.update(keeper.id, { rename: { name: "Gone", nameKey: "gone" } }, PIN)).kind).toBe("updated");
569
+ const relink = await store.applyLinks(first, target, { add: [gone.id], remove: [] }, "u");
570
+ expect(relink.kind === "applied" && [relink.added, relink.skipped]).toEqual([[], [gone.id]]);
571
+ });
572
+ it("removing one tag leaves every other tag's links, on all targets, untouched", async () => {
573
+ const { workspace, store, make } = await setup();
574
+ const [doomed, keeper] = [await make(store, "doomed"), await make(store, "keeper")];
575
+ const [one, two] = [await harness.addTarget(workspace, first), await harness.addTarget(workspace, second)];
576
+ await store.applyLinks(first, one, { add: [doomed.id, keeper.id], remove: [] }, "u");
577
+ await store.applyLinks(second, two, { add: [doomed.id, keeper.id], remove: [] }, "u");
578
+ expect((await store.remove(doomed.id)).kind).toBe("removed");
579
+ expect(ids(await store.tagsForTarget(first, one))).toEqual([keeper.id]);
580
+ expect(ids(await store.tagsForTarget(second, two))).toEqual([keeper.id]);
581
+ const counts = (await store.counts([keeper.id])).get(keeper.id);
582
+ expect([counts?.[first], counts?.[second]]).toEqual([1, 1]);
583
+ });
584
+ it("one save can add and remove together, and an empty save on an existing target applies nothing", async () => {
585
+ const { workspace, store, make } = await setup();
586
+ const [a, b] = [await make(store, "a"), await make(store, "b")];
587
+ const target = await harness.addTarget(workspace, first);
588
+ expect(await store.applyLinks(first, target, { add: [], remove: [] }, "u")).toEqual({ kind: "applied", added: [], removed: [], skipped: [] });
589
+ await store.applyLinks(first, target, { add: [a.id], remove: [] }, "u");
590
+ const swap = await store.applyLinks(first, target, { add: [b.id], remove: [a.id] }, "u");
591
+ expect(swap).toEqual({ kind: "applied", added: [b.id], removed: [a.id], skipped: [] });
592
+ expect(ids(await store.tagsForTarget(first, target))).toEqual([b.id]);
593
+ });
594
+ it("a tag can be added again after it was removed from the same target", async () => {
595
+ const { workspace, store, make } = await setup();
596
+ const a = await make(store, "a");
597
+ const target = await harness.addTarget(workspace, first);
598
+ await store.applyLinks(first, target, { add: [a.id], remove: [] }, "u");
599
+ await store.applyLinks(first, target, { add: [], remove: [a.id] }, "u");
600
+ const again = await store.applyLinks(first, target, { add: [a.id], remove: [] }, "u");
601
+ expect(again.kind === "applied" && again.added).toEqual([a.id]);
602
+ expect(ids(await store.tagsForTarget(first, target))).toEqual([a.id]);
603
+ expect((await store.counts([a.id])).get(a.id)?.[first]).toBe(1);
604
+ });
605
+ it("repeated ids in add or remove are applied once and reported once", async () => {
606
+ const { workspace, store, make } = await setup();
607
+ const a = await make(store, "a");
608
+ const target = await harness.addTarget(workspace, first);
609
+ const added = await store.applyLinks(first, target, { add: [a.id, a.id], remove: [] }, "u");
610
+ expect(added.kind === "applied" && [added.added, added.skipped]).toEqual([[a.id], []]);
611
+ const ghosts = await store.applyLinks(first, target, { add: ["ghost", "ghost"], remove: [] }, "u");
612
+ expect(ghosts.kind === "applied" && ghosts.skipped).toEqual(["ghost"]);
613
+ const removed = await store.applyLinks(first, target, { add: [], remove: [a.id, a.id] }, "u");
614
+ expect(removed.kind === "applied" && removed.removed).toEqual([a.id]);
615
+ });
616
+ it("skipped is only the ids in add that are not this workspace's tags; unknown ids in remove are ignored silently", async () => {
617
+ const { workspace, store, make } = await setup();
618
+ const a = await make(store, "a");
619
+ const target = await harness.addTarget(workspace, first);
620
+ const result = await store.applyLinks(first, target, { add: ["unknown", a.id], remove: ["also-unknown"] }, "u");
621
+ expect(result.kind === "applied" && [result.added, result.removed, result.skipped]).toEqual([[a.id], [], ["unknown"]]);
622
+ });
623
+ });
624
+ }
625
+
626
+ // src/tags/server/contract-scoping.ts
627
+ function defineTagStoreScopingContract(kit) {
628
+ const { describe, it, expect } = kit.api;
629
+ const { harness, first, second, third, setup } = kit;
630
+ describe("TagStore contract: tenancy, targets and counts", () => {
631
+ it("tenancy: another workspace's tag and target are invisible and untouchable (skipped when added, reported as nothing when removed)", async () => {
632
+ const { workspace, store, make } = await setup();
633
+ const otherWorkspace = await harness.newWorkspace();
634
+ const other = harness.storeFor(otherWorkspace);
635
+ const foreignTag = await make(other, "foreign");
636
+ await other.update(foreignTag.id, { pinned: true }, PIN);
637
+ const foreignTarget = await harness.addTarget(otherWorkspace, first);
638
+ await other.applyLinks(first, foreignTarget, { add: [foreignTag.id], remove: [] }, "o");
639
+ const target = await harness.addTarget(workspace, first);
640
+ const added = await store.applyLinks(first, target, { add: [foreignTag.id], remove: [] }, "u");
641
+ expect(added.kind === "applied" && [added.added, added.skipped]).toEqual([[], [foreignTag.id]]);
642
+ const removed = await store.applyLinks(first, target, { add: [], remove: [foreignTag.id] }, "u");
643
+ expect(removed.kind === "applied" && [removed.removed, removed.skipped]).toEqual([[], []]);
644
+ expect(ids(await other.tagsForTarget(first, foreignTarget))).toEqual([foreignTag.id]);
645
+ expect(await store.applyLinks(first, foreignTarget, { add: [], remove: [] }, "u")).toEqual({ kind: "targetGone" });
646
+ expect(await store.targetExists(first, foreignTarget)).toBe(false);
647
+ expect(await store.tagsForTarget(first, foreignTarget)).toEqual([]);
648
+ expect((await store.tagsForTargets(first, [foreignTarget])).size).toBe(0);
649
+ expect(await store.get(foreignTag.id)).toBeNull();
650
+ expect(await store.findByKey(foreignTag.nameKey)).toBeNull();
651
+ expect(await store.update(foreignTag.id, { color: "red" }, PIN)).toEqual({ kind: "notFound" });
652
+ expect(await store.remove(foreignTag.id)).toEqual({ kind: "notFound" });
653
+ expect((await store.getMany([foreignTag.id])).length).toBe(0);
654
+ expect((await store.listPinned(10)).length).toBe(0);
655
+ expect((await store.counts([foreignTag.id])).size).toBe(0);
656
+ expect((await store.counts()).has(foreignTag.id)).toBe(false);
657
+ expect((await store.list()).length).toBe(0);
658
+ expect((await other.get(foreignTag.id))?.pinned).toBe(true);
659
+ });
660
+ it("only this workspace's pinned tags count toward the pin limit, and only its keys count for a rename", async () => {
661
+ const { store, make } = await setup();
662
+ const other = harness.storeFor(await harness.newWorkspace());
663
+ const foreign = await make(other, "taken");
664
+ await other.update(foreign.id, { pinned: true }, PIN);
665
+ const mine = await make(store, "mine");
666
+ expect((await store.update(mine.id, { pinned: true }, { maxPinned: 1 })).kind).toBe("updated");
667
+ const renamed = await store.update(mine.id, { rename: { name: "Taken", nameKey: "taken" } }, PIN);
668
+ expect(renamed.kind).toBe("updated");
669
+ });
670
+ it("calls aimed at another workspace's tag change nothing of it, not even before answering notFound", async () => {
671
+ const { store, make } = await setup();
672
+ const theirWorkspace = await harness.newWorkspace();
673
+ const other = harness.storeFor(theirWorkspace);
674
+ const foreign = await make(other, "shared", "red");
675
+ await other.update(foreign.id, { pinned: true }, PIN);
676
+ const foreignTarget = await harness.addTarget(theirWorkspace, first);
677
+ await other.applyLinks(first, foreignTarget, { add: [foreign.id], remove: [] }, "u");
678
+ const before = core(await other.get(foreign.id));
679
+ expect(await store.update(foreign.id, { rename: { name: "X", nameKey: "x" }, color: "blue", pinned: false }, PIN)).toEqual({ kind: "notFound" });
680
+ expect(await store.update(foreign.id, { color: "blue" }, PIN)).toEqual({ kind: "notFound" });
681
+ expect(await store.update(foreign.id, { pinned: false }, PIN)).toEqual({ kind: "notFound" });
682
+ expect(await store.update(foreign.id, {}, PIN)).toEqual({ kind: "notFound" });
683
+ expect(await store.remove(foreign.id)).toEqual({ kind: "notFound" });
684
+ const mine = await make(store, "shared");
685
+ expect((await store.remove(mine.id)).kind).toBe("removed");
686
+ expect(core(await other.get(foreign.id))).toEqual(before);
687
+ expect(ids(await other.tagsForTarget(first, foreignTarget))).toEqual([foreign.id]);
688
+ expect((await other.counts([foreign.id])).get(foreign.id)?.[first]).toBe(1);
689
+ });
690
+ it("an update of a tag that does not exist is notFound before anything else is looked at", async () => {
691
+ const { store, make } = await setup();
692
+ await make(store, "taken");
693
+ expect(await store.update("missing", {}, PIN)).toEqual({ kind: "notFound" });
694
+ expect(await store.update("missing", { pinned: true }, { maxPinned: 0 })).toEqual({ kind: "notFound" });
695
+ expect(await store.update("missing", { rename: { name: "Taken", nameKey: "taken" } }, PIN)).toEqual({ kind: "notFound" });
696
+ });
697
+ it("an update of one tag leaves every other tag as it was", async () => {
698
+ const { store, make } = await setup();
699
+ const [x, y] = [await make(store, "x", "red"), await make(store, "y", "green")];
700
+ await store.update(y.id, { pinned: true }, PIN);
701
+ await store.update(x.id, { pinned: true }, PIN);
702
+ const untouched = core(await store.get(y.id));
703
+ await store.update(x.id, { color: "blue" }, PIN);
704
+ await store.update(x.id, { rename: { name: "X2", nameKey: "x2" } }, PIN);
705
+ await store.update(x.id, { pinned: false }, PIN);
706
+ expect(core(await store.get(y.id))).toEqual(untouched);
707
+ });
708
+ it("no write touches anything it was not asked to: other tags, their links, their counts and the other workspace stay exactly as they were", async () => {
709
+ const { workspace, store, make } = await setup();
710
+ const otherWorkspace = await harness.newWorkspace();
711
+ const other = harness.storeFor(otherWorkspace);
712
+ const seed = async (s, ws) => {
713
+ const [dup, keep] = [await make(s, "dup", "red"), await make(s, "keep", "green")];
714
+ await s.update(dup.id, { pinned: true }, PIN);
715
+ const target = await harness.addTarget(ws, first);
716
+ await s.applyLinks(first, target, { add: [dup.id, keep.id], remove: [] }, "u");
717
+ return { dup, keep, target };
718
+ };
719
+ const fresh = await make(store, "fresh");
720
+ const mine = await seed(store, workspace);
721
+ const theirs = await seed(other, otherWorkspace);
722
+ const snap = async (s, target, skip = []) => {
723
+ const counts = await s.counts();
724
+ return { tags: cores((await s.list()).filter((tag) => !skip.includes(tag.id))), links: ids(await s.tagsForTarget(first, target)).filter((id) => !skip.includes(id)), counts: [...counts.entries()].filter(([id]) => !skip.includes(id)).sort() };
725
+ };
726
+ const theirsBefore = await snap(other, theirs.target);
727
+ const operations = [
728
+ ["create", [], async () => store.create({ name: "brand new", nameKey: "brand-new", color: "blue" }, LIMITS)],
729
+ ["recolour", [mine.dup.id], async () => store.update(mine.dup.id, { color: "blue" }, PIN)],
730
+ ["unpin", [mine.dup.id], async () => store.update(mine.dup.id, { pinned: false }, PIN)],
731
+ ["pin", [mine.dup.id], async () => store.update(mine.dup.id, { pinned: true }, PIN)],
732
+ ["rename", [mine.dup.id], async () => store.update(mine.dup.id, { rename: { name: "Dup 2", nameKey: "dup-2" } }, PIN)],
733
+ ["link to a missing target", [], async () => store.applyLinks(first, "nothing", { add: [fresh.id], remove: [] }, "u")],
734
+ ["link to a target under the wrong kind", [], async () => store.applyLinks(second, mine.target, { add: [fresh.id], remove: [] }, "u")],
735
+ ["link another workspace's tag", [], async () => store.applyLinks(first, mine.target, { add: [theirs.dup.id], remove: [] }, "u")],
736
+ ["link a tag", [fresh.id], async () => store.applyLinks(first, mine.target, { add: [fresh.id], remove: [] }, "u")],
737
+ ["unlink a tag", [fresh.id], async () => store.applyLinks(first, mine.target, { add: [], remove: [fresh.id] }, "u")],
738
+ ["remove a tag", [fresh.id], async () => store.remove(fresh.id)]
739
+ ];
740
+ for (const [name, touched, run] of operations) {
741
+ const skipNow = async () => {
742
+ const created = (await store.findByKey("brand-new"))?.id;
743
+ return created ? [...touched, created] : touched;
744
+ };
745
+ const before = await snap(store, mine.target, await skipNow());
746
+ await run();
747
+ const after = await snap(store, mine.target, await skipNow());
748
+ expect([name, after]).toEqual([name, before]);
749
+ expect([name, await snap(other, theirs.target)]).toEqual([name, theirsBefore]);
750
+ }
751
+ const dupLinked = ids(await store.tagsForTarget(first, mine.target));
752
+ expect(dupLinked.includes(mine.dup.id)).toBe(true);
753
+ });
754
+ it("the same tag name can exist in two workspaces", async () => {
755
+ const { store, make } = await setup();
756
+ const other = harness.storeFor(await harness.newWorkspace());
757
+ const mine = await make(store, "Shared");
758
+ const theirs = await make(other, "Shared");
759
+ expect((await store.findByKey("shared"))?.id).toBe(mine.id);
760
+ expect((await other.findByKey("shared"))?.id).toBe(theirs.id);
761
+ });
762
+ it("a target that never existed or was deleted is targetGone", async () => {
763
+ const { workspace, store, make } = await setup();
764
+ const a = await make(store, "a");
765
+ expect(await store.applyLinks(first, "nothing", { add: [a.id], remove: [] }, "u")).toEqual({ kind: "targetGone" });
766
+ const target = await harness.addTarget(workspace, first);
767
+ expect(await store.targetExists(first, target)).toBe(true);
768
+ await harness.removeTarget(workspace, first, target);
769
+ expect(await store.targetExists(first, target)).toBe(false);
770
+ expect(await store.applyLinks(first, target, { add: [a.id], remove: [] }, "u")).toEqual({ kind: "targetGone" });
771
+ });
772
+ it("a target id asked for under the wrong kind is targetGone, not a target of that kind", async () => {
773
+ const { workspace, store, make } = await setup();
774
+ const a = await make(store, "a");
775
+ const target = await harness.addTarget(workspace, first);
776
+ expect(await store.targetExists(second, target)).toBe(false);
777
+ expect(await store.applyLinks(second, target, { add: [a.id], remove: [] }, "u")).toEqual({ kind: "targetGone" });
778
+ expect(await store.tagsForTarget(second, target)).toEqual([]);
779
+ expect(await store.tagsForTarget(first, target)).toEqual([]);
780
+ });
781
+ const sharedId = harness.addTargetWithId ? it : it.skip ?? it;
782
+ sharedId("two targets of different kinds that share one id keep separate tags", async () => {
783
+ const { workspace, store, make } = await setup();
784
+ const [a, b] = [await make(store, "a"), await make(store, "b")];
785
+ await harness.addTargetWithId?.(workspace, first, "7");
786
+ await harness.addTargetWithId?.(workspace, second, "7");
787
+ await store.applyLinks(first, "7", { add: [a.id], remove: [] }, "u");
788
+ await store.applyLinks(second, "7", { add: [b.id], remove: [] }, "u");
789
+ expect(ids(await store.tagsForTarget(first, "7"))).toEqual([a.id]);
790
+ expect(ids(await store.tagsForTarget(second, "7"))).toEqual([b.id]);
791
+ expect(ids((await store.tagsForTargets(first, ["7"])).get("7") ?? [])).toEqual([a.id]);
792
+ expect([(await store.counts([a.id])).get(a.id)?.[first], (await store.counts([a.id])).get(a.id)?.[second]]).toEqual([1, 0]);
793
+ });
794
+ sharedId("with one id under two kinds, linking and unlinking the same tag on one kind never touches the other", async () => {
795
+ const { workspace, store, make } = await setup();
796
+ const t = await make(store, "t");
797
+ await harness.addTargetWithId?.(workspace, first, "9");
798
+ await harness.addTargetWithId?.(workspace, second, "9");
799
+ const onFirst = await store.applyLinks(first, "9", { add: [t.id], remove: [] }, "u");
800
+ const onSecond = await store.applyLinks(second, "9", { add: [t.id], remove: [] }, "u");
801
+ expect([onFirst.kind === "applied" && onFirst.added, onSecond.kind === "applied" && onSecond.added]).toEqual([[t.id], [t.id]]);
802
+ const gone = await store.applyLinks(second, "9", { add: [], remove: [t.id] }, "u");
803
+ expect(gone.kind === "applied" && gone.removed).toEqual([t.id]);
804
+ expect(ids(await store.tagsForTarget(first, "9"))).toEqual([t.id]);
805
+ expect(ids(await store.tagsForTarget(second, "9"))).toEqual([]);
806
+ const again = await store.applyLinks(second, "9", { add: [], remove: [t.id] }, "u");
807
+ expect(again.kind === "applied" && again.removed).toEqual([]);
808
+ expect(ids(await store.tagsForTarget(first, "9"))).toEqual([t.id]);
809
+ });
810
+ sharedId("deleting a target under one kind leaves the same id's tags under another kind alone", async () => {
811
+ const { workspace, store, make } = await setup();
812
+ const t = await make(store, "t");
813
+ await harness.addTargetWithId?.(workspace, first, "5");
814
+ await harness.addTargetWithId?.(workspace, second, "5");
815
+ await store.applyLinks(first, "5", { add: [t.id], remove: [] }, "u");
816
+ await store.applyLinks(second, "5", { add: [t.id], remove: [] }, "u");
817
+ await harness.removeTarget(workspace, first, "5");
818
+ expect(await store.targetExists(first, "5")).toBe(false);
819
+ expect(ids(await store.tagsForTarget(second, "5"))).toEqual([t.id]);
820
+ expect((await store.counts([t.id])).get(t.id)?.[second]).toBe(1);
821
+ });
822
+ it("tagsForTargets returns only targets that have tags, and not a target of another kind that was asked for", async () => {
823
+ const { workspace, store, make } = await setup();
824
+ const [a, b, c] = [await make(store, "a"), await make(store, "b"), await make(store, "c")];
825
+ const t1 = await harness.addTarget(workspace, first);
826
+ const t2 = await harness.addTarget(workspace, first);
827
+ const t3 = await harness.addTarget(workspace, first);
828
+ const other = await harness.addTarget(workspace, second);
829
+ const notAsked = await harness.addTarget(workspace, first);
830
+ await store.applyLinks(first, notAsked, { add: [a.id], remove: [] }, "u");
831
+ await store.applyLinks(first, t1, { add: [a.id, b.id, c.id], remove: [] }, "u");
832
+ await store.applyLinks(first, t2, { add: [b.id], remove: [] }, "u");
833
+ await store.applyLinks(second, other, { add: [a.id], remove: [] }, "u");
834
+ expect((await store.tagsForTargets(first, [])).size).toBe(0);
835
+ expect((await store.tagsForTargets(first, [t1, t1])).get(t1)?.length).toBe(3);
836
+ const map = await store.tagsForTargets(first, [t1, t2, t3, other]);
837
+ expect([...map.keys()].sort()).toEqual([t1, t2].sort());
838
+ expect(cores(map.get(t1) ?? [])).toEqual(cores([a, b, c]));
839
+ expect(cores(await store.tagsForTarget(first, t1))).toEqual(cores([a, b, c]));
840
+ expect(ids(map.get(t2) ?? [])).toEqual([b.id]);
841
+ });
842
+ it("counts include every tag with zero for every kind, count per kind, honour ids, and drop when a link is removed", async () => {
843
+ const { workspace, store, make } = await setup();
844
+ const [a, lonely] = [await make(store, "a"), await make(store, "lonely")];
845
+ const targets = [];
846
+ for (const type of harness.targetTypes) targets.push([type, await harness.addTarget(workspace, type)]);
847
+ for (const [type, id] of targets) await store.applyLinks(type, id, { add: [a.id], remove: [] }, "u");
848
+ const counts = await store.counts();
849
+ expect(Object.keys(counts.get(lonely.id) ?? {}).sort()).toEqual([...harness.targetTypes].sort());
850
+ expect(Object.values(counts.get(lonely.id) ?? {})).toEqual(harness.targetTypes.map(() => 0));
851
+ expect(Object.values(counts.get(a.id) ?? {})).toEqual(harness.targetTypes.map(() => 1));
852
+ expect([...(await store.counts([a.id])).keys()]).toEqual([a.id]);
853
+ expect((await store.counts([])).size).toBe(0);
854
+ await store.applyLinks(third, targets[2][1], { add: [], remove: [a.id] }, "u");
855
+ expect((await store.counts()).get(a.id)?.[third]).toBe(0);
856
+ for (let i = 0; i < 2; i++) await store.applyLinks(first, await harness.addTarget(workspace, first), { add: [a.id], remove: [] }, "u");
857
+ expect((await store.counts([a.id])).get(a.id)?.[first]).toBe(3);
858
+ });
859
+ const mid = harness.armBeforeLinkWrite ? it : it.skip ?? it;
860
+ mid("a tag deleted between the check and the write is tagGone and nothing is applied, additions and removals alike", async () => {
861
+ const { workspace, store, make } = await setup();
862
+ const [keep, doomed, old] = [await make(store, "keep"), await make(store, "doomed"), await make(store, "old")];
863
+ const target = await harness.addTarget(workspace, first);
864
+ await store.applyLinks(first, target, { add: [old.id], remove: [] }, "u");
865
+ harness.armBeforeLinkWrite?.(async () => {
866
+ await store.remove(doomed.id);
867
+ });
868
+ expect(await store.applyLinks(first, target, { add: [keep.id, doomed.id], remove: [old.id] }, "u")).toEqual({ kind: "tagGone" });
869
+ expect(ids(await store.tagsForTarget(first, target))).toEqual([old.id]);
870
+ });
871
+ mid("a target deleted between the check and the write is targetGone", async () => {
872
+ const { workspace, store, make } = await setup();
873
+ const a = await make(store, "a");
874
+ const target = await harness.addTarget(workspace, first);
875
+ harness.armBeforeLinkWrite?.(async () => {
876
+ await harness.removeTarget(workspace, first, target);
877
+ });
878
+ expect(await store.applyLinks(first, target, { add: [a.id], remove: [] }, "u")).toEqual({ kind: "targetGone" });
879
+ expect((await store.counts()).get(a.id)?.[first]).toBe(0);
880
+ });
881
+ mid("a target deleted between the check and the write is targetGone for a removal too, not an empty success", async () => {
882
+ const { workspace, store, make } = await setup();
883
+ const a = await make(store, "a");
884
+ const target = await harness.addTarget(workspace, first);
885
+ await store.applyLinks(first, target, { add: [a.id], remove: [] }, "u");
886
+ harness.armBeforeLinkWrite?.(async () => {
887
+ await harness.removeTarget(workspace, first, target);
888
+ });
889
+ expect(await store.applyLinks(first, target, { add: [], remove: [a.id] }, "u")).toEqual({ kind: "targetGone" });
890
+ expect((await store.counts()).get(a.id)?.[first]).toBe(0);
891
+ });
892
+ });
893
+ }
894
+
895
+ // src/tags/server/contract-basics.ts
896
+ function defineTagStoreBasicsContract(api, harness) {
897
+ const kit = makeKit(api, harness);
898
+ defineTagStoreTagsContract(kit);
899
+ defineTagStoreLinksContract(kit);
900
+ defineTagStoreScopingContract(kit);
901
+ }
902
+
903
+ // src/tags/server/contract-concurrency.ts
904
+ var BIG = { maxTags: 1e3 };
905
+ var PIN2 = { maxPinned: 1e3 };
906
+ var key2 = (name) => name.toLowerCase();
907
+ function count(results, kind) {
908
+ return results.filter((r) => r.kind === kind).length;
909
+ }
910
+ function many(n, make) {
911
+ return Promise.all(Array.from({ length: n }, (_, i) => make(i)));
912
+ }
913
+ function defineTagStoreConcurrencyContract(api, harness) {
914
+ const { describe, it, expect } = api;
915
+ const [first] = harness.targetTypes;
916
+ async function setup() {
917
+ const workspace = await harness.newWorkspace();
918
+ const store = harness.storeFor(workspace);
919
+ const make = async (s, name) => {
920
+ const result = await s.create({ name, nameKey: key2(name), color: "neutral" }, BIG);
921
+ if (result.kind !== "created") throw new Error(`setup: could not create ${name}: ${result.kind}`);
922
+ return result.tag;
923
+ };
924
+ return { workspace, store, make };
925
+ }
926
+ describe("TagStore contract: simultaneous callers", () => {
927
+ it("ten creates with different names at a limit of 3 give exactly 3 created and 7 limit", async () => {
928
+ const { store } = await setup();
929
+ const results = await many(10, (i) => store.create({ name: `t${i}`, nameKey: `t${i}`, color: "neutral" }, { maxTags: 3 }));
930
+ expect([count(results, "created"), count(results, "limit")]).toEqual([3, 7]);
931
+ expect((await store.list()).length).toBe(3);
932
+ });
933
+ it("ten creates of the same name give exactly one created and nine duplicates", async () => {
934
+ const { store } = await setup();
935
+ const results = await many(10, () => store.create({ name: "Same", nameKey: "same", color: "neutral" }, BIG));
936
+ expect([count(results, "created"), count(results, "duplicate")]).toEqual([1, 9]);
937
+ expect((await store.list()).length).toBe(1);
938
+ });
939
+ it("ten pins at a limit of 3 give exactly 3 updated and 7 pinLimit", async () => {
940
+ const { store, make } = await setup();
941
+ const tags = [];
942
+ for (let i = 0; i < 10; i++) tags.push(await make(store, `p${i}`));
943
+ const results = await Promise.all(tags.map((tag) => store.update(tag.id, { pinned: true }, { maxPinned: 3 })));
944
+ expect([count(results, "updated"), count(results, "pinLimit")]).toEqual([3, 7]);
945
+ expect((await store.listPinned(100)).length).toBe(3);
946
+ });
947
+ it("two renames onto the same key give one updated and one duplicate", async () => {
948
+ const { store, make } = await setup();
949
+ const [a, b] = [await make(store, "a"), await make(store, "b")];
950
+ const results = await Promise.all([a, b].map((tag) => store.update(tag.id, { rename: { name: "Target", nameKey: "target" } }, PIN2)));
951
+ expect([count(results, "updated"), count(results, "duplicate")]).toEqual([1, 1]);
952
+ });
953
+ it("a create and a rename onto the same key at once give exactly one winner", async () => {
954
+ const { store, make } = await setup();
955
+ const b = await make(store, "b");
956
+ const [created, renamed] = await Promise.all([
957
+ store.create({ name: "x", nameKey: "x", color: "neutral" }, BIG),
958
+ store.update(b.id, { rename: { name: "x", nameKey: "x" } }, PIN2)
959
+ ]);
960
+ expect([created.kind === "created", renamed.kind === "updated"].filter(Boolean).length).toBe(1);
961
+ expect((await store.list()).filter((tag) => tag.nameKey === "x").length).toBe(1);
962
+ });
963
+ it("two updates of one tag at once give a consistent before and after chain", async () => {
964
+ const { store, make } = await setup();
965
+ const tag = await make(store, "one");
966
+ const [red, blue] = await Promise.all([store.update(tag.id, { color: "red" }, PIN2), store.update(tag.id, { color: "blue" }, PIN2)]);
967
+ if (red.kind !== "updated" || blue.kind !== "updated") throw new Error("both updates should succeed");
968
+ const chain = [red, blue].map((r) => [r.before.color, r.after.color]).sort();
969
+ const redFirst = [["neutral", "red"], ["red", "blue"]].sort();
970
+ const blueFirst = [["blue", "red"], ["neutral", "blue"]].sort();
971
+ expect([JSON.stringify(redFirst), JSON.stringify(blueFirst)].includes(JSON.stringify(chain))).toBe(true);
972
+ });
973
+ it("saving tags onto a target that is deleted at the same moment never leaves a link behind", async () => {
974
+ const { workspace, store, make } = await setup();
975
+ const tag = await make(store, "a");
976
+ for (let delay = 0; delay < 12; delay++) {
977
+ const target = await harness.addTarget(workspace, first);
978
+ const apply = store.applyLinks(first, target, { add: [tag.id], remove: [] }, "u");
979
+ const remove = (async () => {
980
+ for (let i = 0; i < delay; i++) await Promise.resolve();
981
+ await harness.removeTarget(workspace, first, target);
982
+ })();
983
+ await Promise.all([apply, remove]);
984
+ expect(await store.targetExists(first, target)).toBe(false);
985
+ }
986
+ expect((await store.counts([tag.id])).get(tag.id)?.[first]).toBe(0);
987
+ });
988
+ it("ten removes of one tag give exactly one removed and nine notFound", async () => {
989
+ const { store, make } = await setup();
990
+ const tag = await make(store, "doomed");
991
+ const results = await many(10, () => store.remove(tag.id));
992
+ expect([count(results, "removed"), count(results, "notFound")]).toEqual([1, 9]);
993
+ });
994
+ it("two identical saves at once report the added tag exactly once in total", async () => {
995
+ const { workspace, store, make } = await setup();
996
+ const tag = await make(store, "a");
997
+ const target = await harness.addTarget(workspace, first);
998
+ const results = await many(2, () => store.applyLinks(first, target, { add: [tag.id], remove: [] }, "u"));
999
+ const reported = results.flatMap((r) => r.kind === "applied" ? r.added : []);
1000
+ expect(reported).toEqual([tag.id]);
1001
+ expect((await store.tagsForTarget(first, target)).length).toBe(1);
1002
+ });
1003
+ it("ten saves adding ten different tags all land, and two saves removing the same tag report the removal once", async () => {
1004
+ const { workspace, store, make } = await setup();
1005
+ const tags = [];
1006
+ for (let i = 0; i < 10; i++) tags.push(await make(store, `l${i}`));
1007
+ const target = await harness.addTarget(workspace, first);
1008
+ const adds = await Promise.all(tags.map((tag) => store.applyLinks(first, target, { add: [tag.id], remove: [] }, "u")));
1009
+ expect(count(adds, "applied")).toBe(10);
1010
+ expect((await store.tagsForTarget(first, target)).length).toBe(10);
1011
+ const removals = await many(2, () => store.applyLinks(first, target, { add: [], remove: [tags[0].id] }, "u"));
1012
+ expect(removals.flatMap((r) => r.kind === "applied" ? r.removed : [])).toEqual([tags[0].id]);
1013
+ });
1014
+ it("a tag removed while a save links it ends up gone from the target and the counts, whichever finishes first", async () => {
1015
+ const { workspace, store, make } = await setup();
1016
+ const tag = await make(store, "racy");
1017
+ const target = await harness.addTarget(workspace, first);
1018
+ const [applied, removed] = await Promise.all([store.applyLinks(first, target, { add: [tag.id], remove: [] }, "u"), store.remove(tag.id)]);
1019
+ expect(removed.kind).toBe("removed");
1020
+ expect(["applied", "tagGone"].includes(applied.kind)).toBe(true);
1021
+ expect((await store.tagsForTarget(first, target)).length).toBe(0);
1022
+ expect((await store.counts()).has(tag.id)).toBe(false);
1023
+ });
1024
+ });
1025
+ }
1026
+
1027
+ // src/tags/server/contract.ts
1028
+ function defineTagStoreContract(api, harness) {
1029
+ api.describe("TagStore contract", () => {
1030
+ defineTagStoreBasicsContract(api, harness);
1031
+ defineTagStoreConcurrencyContract(api, harness);
1032
+ api.it("cleans up", async () => {
1033
+ await harness.cleanup();
1034
+ });
1035
+ });
1036
+ }
1037
+ export {
1038
+ createMemoryTagHarness,
1039
+ createMemoryTagWorld,
1040
+ createTagService,
1041
+ defineTagStoreContract,
1042
+ planTagChanges
1043
+ };