@ordinatio/entities 1.1.0 → 1.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/README.md +1 -1
- package/dist/chunk-KPZRBM5J.mjs +67 -0
- package/dist/constants-DSRJ-l7Z.d.mts +15 -0
- package/dist/tags/index.d.mts +3 -14
- package/dist/tags/index.mjs +13 -53
- package/dist/tags/server/index.d.mts +282 -0
- package/dist/tags/server/index.mjs +1032 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -193,7 +193,7 @@ await app.entities.createNote({ entityType: 'client', entityId: 'client-123', co
|
|
|
193
193
|
|
|
194
194
|
### Tags Module (`@ordinatio/entities/tags`, experimental)
|
|
195
195
|
|
|
196
|
-
Pure, zero-dependency helpers for tags, on their own subpath so a browser component can import them without pulling in the rest of the package (no Prisma, zod or Node modules): `tagNameKey` / `cleanTagName` / `checkTagName` (the case-insensitive identity of a name; **pinned to its first shipped behaviour** because apps store the key in a unique column), `TAG_COLORS` / `isTagColor` / `resolveTagColor`, `DEFAULT_TAG_LIMITS` (name length 40, 50 changes per save, 15 pinned, 500 per workspace: defaults, a store takes them as arguments), and the picker helpers `describeTagChanges`, `tagSetKey`, `rebaseSelection`, `mergeTagOptions`. The `./schemas` and `./errors` subpaths exist only in the workspace (source) exports map and are not part of the published package yet.
|
|
196
|
+
Pure, zero-dependency helpers for tags, on their own subpath so a browser component can import them without pulling in the rest of the package (no Prisma, zod or Node modules): `tagNameKey` / `cleanTagName` / `checkTagName` (the case-insensitive identity of a name; **pinned to its first shipped behaviour** because apps store the key in a unique column), `TAG_COLORS` / `isTagColor` / `resolveTagColor`, `DEFAULT_TAG_LIMITS` (name length 40, 50 changes per save, 15 pinned, 500 per workspace: defaults, a store takes them as arguments), and the picker helpers `describeTagChanges`, `tagSetKey`, `rebaseSelection`, `mergeTagOptions`. The `./schemas` and `./errors` subpaths exist only in the workspace (source) exports map and are not part of the published package yet. On the separate server entry `@ordinatio/entities/tags/server` (EXPERIMENTAL until a second product has used it): the `TagStore` interface (built per workspace, so the package never sees a tenant id; atomic, typed, limit-aware operations), `createTagService(store, limits?)` (the tag rules over any store: validate and clean names and colours, create and find-or-create, rename/recolour/pin, remove, apply only the changes a user made to one item's tags, and read tags back sorted; every outcome a user can cause is a typed `{ ok: false, reason, ... }` result with the context an app needs for its message, and a store result it does not know throws; a store that throws is not caught, so an app that must never fail a page, such as a sidebar of pinned tags, wraps that one call itself; `pinned()` asks the store for the first `maxPinned` pinned tags, so make `listPinned` return them in a stable order such as by name), `planTagChanges`, an in-memory reference store (`createMemoryTagWorld`, `createMemoryTagHarness`) and the store contract suite `defineTagStoreContract` that any storage adapter must pass (framework-agnostic: pass your runner's `describe`/`it`/`expect`). Pin this package's exact version while the server entry is experimental: adding a required store method or a contract rule can break an adapter that passed before. The concurrency tests are meaningful only against a real store: run them against your database with a connection pool of at least 10. **Adapters and mappers must treat an unknown result `kind` or reason as an error**, never as success: adding a kind is a breaking change for an exhaustive switch.
|
|
197
197
|
|
|
198
198
|
### Contacts Module
|
|
199
199
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/tags/constants.ts
|
|
2
|
+
var TAG_COLORS = ["neutral", "red", "amber", "green", "blue", "purple", "pink"];
|
|
3
|
+
var DEFAULT_TAG_LIMITS = Object.freeze({
|
|
4
|
+
maxNameLength: 40,
|
|
5
|
+
maxChangesPerSave: 50,
|
|
6
|
+
maxPinned: 15,
|
|
7
|
+
maxPerWorkspace: 500
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// src/tags/name.ts
|
|
11
|
+
function cleanTagName(name) {
|
|
12
|
+
return name.trim().replace(/\s+/g, " ");
|
|
13
|
+
}
|
|
14
|
+
function tagNameKey(name) {
|
|
15
|
+
return cleanTagName(name).toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
function checkTagName(name, maxLength = DEFAULT_TAG_LIMITS.maxNameLength) {
|
|
18
|
+
const cleaned = cleanTagName(name);
|
|
19
|
+
if (cleaned.length < 1) return { ok: false, reason: "empty", length: cleaned.length };
|
|
20
|
+
if (cleaned.length > maxLength) return { ok: false, reason: "too-long", length: cleaned.length };
|
|
21
|
+
return { ok: true, name: cleaned, key: tagNameKey(cleaned) };
|
|
22
|
+
}
|
|
23
|
+
function isTagColor(value) {
|
|
24
|
+
return typeof value === "string" && TAG_COLORS.includes(value);
|
|
25
|
+
}
|
|
26
|
+
function resolveTagColor(color) {
|
|
27
|
+
if (color === void 0) return { ok: true, color: "neutral" };
|
|
28
|
+
return isTagColor(color) ? { ok: true, color } : { ok: false };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/tags/changes.ts
|
|
32
|
+
function describeTagChanges(before, after) {
|
|
33
|
+
return [
|
|
34
|
+
before.name !== after.name ? `renamed "${before.name}" to "${after.name}"` : "",
|
|
35
|
+
before.color !== after.color ? `changed its color to ${after.color}` : "",
|
|
36
|
+
before.pinned !== after.pinned ? after.pinned ? "pinned it to Bookmarks" : "unpinned it from Bookmarks" : ""
|
|
37
|
+
].filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
function tagSetKey(ids) {
|
|
40
|
+
return [...new Set(ids)].sort().join(",");
|
|
41
|
+
}
|
|
42
|
+
function rebaseSelection(args) {
|
|
43
|
+
const initial = new Set(args.initial);
|
|
44
|
+
const selected = new Set(args.selected);
|
|
45
|
+
const userAdded = [...selected].filter((t) => !initial.has(t));
|
|
46
|
+
const userRemoved = new Set([...initial].filter((t) => !selected.has(t)));
|
|
47
|
+
const next = new Set([...args.server, ...userAdded].filter((t) => !userRemoved.has(t)));
|
|
48
|
+
return { initial: [...new Set(args.server)], selected: [...next] };
|
|
49
|
+
}
|
|
50
|
+
function mergeTagOptions(fresh, createdHere) {
|
|
51
|
+
const freshIds = new Set(fresh.map((t) => t.id));
|
|
52
|
+
return [...fresh, ...createdHere.filter((t) => !freshIds.has(t.id))].sort((a, b) => a.name.localeCompare(b.name));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
TAG_COLORS,
|
|
57
|
+
DEFAULT_TAG_LIMITS,
|
|
58
|
+
cleanTagName,
|
|
59
|
+
tagNameKey,
|
|
60
|
+
checkTagName,
|
|
61
|
+
isTagColor,
|
|
62
|
+
resolveTagColor,
|
|
63
|
+
describeTagChanges,
|
|
64
|
+
tagSetKey,
|
|
65
|
+
rebaseSelection,
|
|
66
|
+
mergeTagOptions
|
|
67
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
declare const TAG_COLORS: readonly ["neutral", "red", "amber", "green", "blue", "purple", "pink"];
|
|
2
|
+
type TagColor = (typeof TAG_COLORS)[number];
|
|
3
|
+
interface TagLimits {
|
|
4
|
+
/** Longest tag name, in characters, after cleaning. */
|
|
5
|
+
maxNameLength: number;
|
|
6
|
+
/** Upper bound on how many tags one save may add or remove: stops a crafted request sending thousands of ids into an IN query. */
|
|
7
|
+
maxChangesPerSave: number;
|
|
8
|
+
/** How many tags may be pinned (shown as bookmarks): pinning more is refused rather than silently ignored. */
|
|
9
|
+
maxPinned: number;
|
|
10
|
+
/** Per-workspace ceiling: forms load every tag, so an unbounded vocabulary would slow every form. */
|
|
11
|
+
maxPerWorkspace: number;
|
|
12
|
+
}
|
|
13
|
+
declare const DEFAULT_TAG_LIMITS: Readonly<TagLimits>;
|
|
14
|
+
|
|
15
|
+
export { DEFAULT_TAG_LIMITS as D, type TagColor as T, TAG_COLORS as a, type TagLimits as b };
|
package/dist/tags/index.d.mts
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
interface TagLimits {
|
|
4
|
-
/** Longest tag name, in characters, after cleaning. */
|
|
5
|
-
maxNameLength: number;
|
|
6
|
-
/** Upper bound on how many tags one save may add or remove: stops a crafted request sending thousands of ids into an IN query. */
|
|
7
|
-
maxChangesPerSave: number;
|
|
8
|
-
/** How many tags may be pinned (shown as bookmarks): pinning more is refused rather than silently ignored. */
|
|
9
|
-
maxPinned: number;
|
|
10
|
-
/** Per-workspace ceiling: forms load every tag, so an unbounded vocabulary would slow every form. */
|
|
11
|
-
maxPerWorkspace: number;
|
|
12
|
-
}
|
|
13
|
-
declare const DEFAULT_TAG_LIMITS: Readonly<TagLimits>;
|
|
1
|
+
import { T as TagColor } from '../constants-DSRJ-l7Z.mjs';
|
|
2
|
+
export { D as DEFAULT_TAG_LIMITS, a as TAG_COLORS, b as TagLimits } from '../constants-DSRJ-l7Z.mjs';
|
|
14
3
|
|
|
15
4
|
/** Trims and collapses every run of whitespace (including tabs and no-break spaces) to one plain space. */
|
|
16
5
|
declare function cleanTagName(name: string): string;
|
|
@@ -75,4 +64,4 @@ interface TagChoice {
|
|
|
75
64
|
*/
|
|
76
65
|
declare function mergeTagOptions<T extends TagChoice>(fresh: readonly T[], createdHere: readonly T[]): T[];
|
|
77
66
|
|
|
78
|
-
export {
|
|
67
|
+
export { type RebasedSelection, type TagChoice, TagColor, type TagNameCheck, type TagSnapshot, checkTagName, cleanTagName, describeTagChanges, isTagColor, mergeTagOptions, rebaseSelection, resolveTagColor, tagNameKey, tagSetKey };
|
package/dist/tags/index.mjs
CHANGED
|
@@ -1,56 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
function tagNameKey(name) {
|
|
15
|
-
return cleanTagName(name).toLowerCase();
|
|
16
|
-
}
|
|
17
|
-
function checkTagName(name, maxLength = DEFAULT_TAG_LIMITS.maxNameLength) {
|
|
18
|
-
const cleaned = cleanTagName(name);
|
|
19
|
-
if (cleaned.length < 1) return { ok: false, reason: "empty", length: cleaned.length };
|
|
20
|
-
if (cleaned.length > maxLength) return { ok: false, reason: "too-long", length: cleaned.length };
|
|
21
|
-
return { ok: true, name: cleaned, key: tagNameKey(cleaned) };
|
|
22
|
-
}
|
|
23
|
-
function isTagColor(value) {
|
|
24
|
-
return typeof value === "string" && TAG_COLORS.includes(value);
|
|
25
|
-
}
|
|
26
|
-
function resolveTagColor(color) {
|
|
27
|
-
if (color === void 0) return { ok: true, color: "neutral" };
|
|
28
|
-
return isTagColor(color) ? { ok: true, color } : { ok: false };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// src/tags/changes.ts
|
|
32
|
-
function describeTagChanges(before, after) {
|
|
33
|
-
return [
|
|
34
|
-
before.name !== after.name ? `renamed "${before.name}" to "${after.name}"` : "",
|
|
35
|
-
before.color !== after.color ? `changed its color to ${after.color}` : "",
|
|
36
|
-
before.pinned !== after.pinned ? after.pinned ? "pinned it to Bookmarks" : "unpinned it from Bookmarks" : ""
|
|
37
|
-
].filter(Boolean);
|
|
38
|
-
}
|
|
39
|
-
function tagSetKey(ids) {
|
|
40
|
-
return [...new Set(ids)].sort().join(",");
|
|
41
|
-
}
|
|
42
|
-
function rebaseSelection(args) {
|
|
43
|
-
const initial = new Set(args.initial);
|
|
44
|
-
const selected = new Set(args.selected);
|
|
45
|
-
const userAdded = [...selected].filter((t) => !initial.has(t));
|
|
46
|
-
const userRemoved = new Set([...initial].filter((t) => !selected.has(t)));
|
|
47
|
-
const next = new Set([...args.server, ...userAdded].filter((t) => !userRemoved.has(t)));
|
|
48
|
-
return { initial: [...new Set(args.server)], selected: [...next] };
|
|
49
|
-
}
|
|
50
|
-
function mergeTagOptions(fresh, createdHere) {
|
|
51
|
-
const freshIds = new Set(fresh.map((t) => t.id));
|
|
52
|
-
return [...fresh, ...createdHere.filter((t) => !freshIds.has(t.id))].sort((a, b) => a.name.localeCompare(b.name));
|
|
53
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_TAG_LIMITS,
|
|
3
|
+
TAG_COLORS,
|
|
4
|
+
checkTagName,
|
|
5
|
+
cleanTagName,
|
|
6
|
+
describeTagChanges,
|
|
7
|
+
isTagColor,
|
|
8
|
+
mergeTagOptions,
|
|
9
|
+
rebaseSelection,
|
|
10
|
+
resolveTagColor,
|
|
11
|
+
tagNameKey,
|
|
12
|
+
tagSetKey
|
|
13
|
+
} from "../chunk-KPZRBM5J.mjs";
|
|
54
14
|
export {
|
|
55
15
|
DEFAULT_TAG_LIMITS,
|
|
56
16
|
TAG_COLORS,
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { b as TagLimits } from '../../constants-DSRJ-l7Z.mjs';
|
|
2
|
+
|
|
3
|
+
/** The fields the rules need. An adapter may return a wider row (createdAt ...): the store is generic over it. */
|
|
4
|
+
interface TagRecord {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
/** The case-insensitive identity of the name (unique per workspace): see `tagNameKey` in `@ordinatio/entities/tags`. */
|
|
8
|
+
nameKey: string;
|
|
9
|
+
color: string;
|
|
10
|
+
pinned: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Named object types so a future limit is an additive field, not a signature change. */
|
|
13
|
+
interface TagCreateLimits {
|
|
14
|
+
maxTags: number;
|
|
15
|
+
}
|
|
16
|
+
interface TagUpdateLimits {
|
|
17
|
+
maxPinned: number;
|
|
18
|
+
}
|
|
19
|
+
type TagCreateResult<T extends TagRecord> = {
|
|
20
|
+
kind: 'created';
|
|
21
|
+
tag: T;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'duplicate';
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'limit';
|
|
26
|
+
};
|
|
27
|
+
interface TagUpdatePatch {
|
|
28
|
+
/** Name and key travel TOGETHER so they cannot disagree. */
|
|
29
|
+
rename?: {
|
|
30
|
+
name: string;
|
|
31
|
+
nameKey: string;
|
|
32
|
+
};
|
|
33
|
+
color?: string;
|
|
34
|
+
pinned?: boolean;
|
|
35
|
+
}
|
|
36
|
+
type TagUpdateResult<T extends TagRecord> = {
|
|
37
|
+
kind: 'updated';
|
|
38
|
+
before: T;
|
|
39
|
+
after: T;
|
|
40
|
+
} | {
|
|
41
|
+
kind: 'notFound';
|
|
42
|
+
} | {
|
|
43
|
+
kind: 'pinLimit';
|
|
44
|
+
} | {
|
|
45
|
+
kind: 'duplicate';
|
|
46
|
+
};
|
|
47
|
+
type TagRemoveResult<T extends TagRecord> = {
|
|
48
|
+
kind: 'removed';
|
|
49
|
+
tag: T;
|
|
50
|
+
} | {
|
|
51
|
+
kind: 'notFound';
|
|
52
|
+
};
|
|
53
|
+
interface TagLinkChange {
|
|
54
|
+
add: string[];
|
|
55
|
+
remove: string[];
|
|
56
|
+
}
|
|
57
|
+
type TagApplyResult = {
|
|
58
|
+
kind: 'applied';
|
|
59
|
+
added: string[];
|
|
60
|
+
removed: string[];
|
|
61
|
+
skipped: string[];
|
|
62
|
+
} | {
|
|
63
|
+
kind: 'targetGone';
|
|
64
|
+
} | {
|
|
65
|
+
kind: 'tagGone';
|
|
66
|
+
};
|
|
67
|
+
interface TagStore<T extends TagRecord = TagRecord, Target extends string = string> {
|
|
68
|
+
/** Every tag of the workspace. ORDER IS UNSPECIFIED: the service sorts. */
|
|
69
|
+
list(): Promise<T[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Pinned tags only, at most `limit`, in ONE bounded query (an app may run this on every page). Order unspecified, but when more are pinned
|
|
72
|
+
* than `limit` the SAME ones should come back each time (for example by name), or the bookmarks would change from page to page.
|
|
73
|
+
*/
|
|
74
|
+
listPinned(limit: number): Promise<T[]>;
|
|
75
|
+
/** The tags of THIS workspace among `ids`; foreign or deleted ids are simply absent. */
|
|
76
|
+
getMany(ids: string[]): Promise<T[]>;
|
|
77
|
+
findByKey(nameKey: string): Promise<T | null>;
|
|
78
|
+
get(id: string): Promise<T | null>;
|
|
79
|
+
/**
|
|
80
|
+
* ATOMIC: the limit is enforced together with the insert, so simultaneous creates can never overshoot it. Precedence: `limit` wins over
|
|
81
|
+
* `duplicate` (a duplicate name when the workspace is full is `limit`).
|
|
82
|
+
*/
|
|
83
|
+
create(input: {
|
|
84
|
+
name: string;
|
|
85
|
+
nameKey: string;
|
|
86
|
+
color: string;
|
|
87
|
+
}, limits: TagCreateLimits): Promise<TagCreateResult<T>>;
|
|
88
|
+
/**
|
|
89
|
+
* ATOMIC. Precedence: `notFound`, then `pinLimit`, then `duplicate`. Pinning a tag that is ALREADY pinned never counts against the limit
|
|
90
|
+
* (the pinned flag is re-read under the lock); unpinning needs no limit. Renaming a tag to the same key it already has (a case change) is
|
|
91
|
+
* not a duplicate. An empty patch is a no-op that returns `updated` with `before` equal to `after`.
|
|
92
|
+
* `before` is read in the same transaction as the write, so `before`/`after` are consistent under concurrent edits.
|
|
93
|
+
*/
|
|
94
|
+
update(id: string, patch: TagUpdatePatch, limits: TagUpdateLimits): Promise<TagUpdateResult<T>>;
|
|
95
|
+
/** The tag's links go with it. Ten parallel removes of one tag give exactly one `removed`. */
|
|
96
|
+
remove(id: string): Promise<TagRemoveResult<T>>;
|
|
97
|
+
targetExists(target: Target, targetId: string): Promise<boolean>;
|
|
98
|
+
/**
|
|
99
|
+
* One transaction. Frozen result shapes:
|
|
100
|
+
* - `skipped` = ids in `add` that are NOT tags of this workspace at the START of the call. Ids in `remove` that are not this workspace's
|
|
101
|
+
* tags are IGNORED silently (never reported, never touched). An `add` that is already linked is in neither `added` nor `skipped`.
|
|
102
|
+
* - A tag that existed at the start but is gone when the write happens: `tagGone`, nothing applied. A target that vanished: `targetGone`.
|
|
103
|
+
* - `added` / `removed` come from rows the database REALLY inserted / deleted, so concurrent saves never double-report one change.
|
|
104
|
+
* An id in BOTH `add` and `remove` is undefined behaviour: the service never sends it (`planTagChanges` drops it from `remove`).
|
|
105
|
+
* `actorId` is stored with each new link (who tagged it); it is opaque to this package.
|
|
106
|
+
*/
|
|
107
|
+
applyLinks(target: Target, targetId: string, change: TagLinkChange, actorId: string): Promise<TagApplyResult>;
|
|
108
|
+
/** Tags on one target. Order unspecified. */
|
|
109
|
+
tagsForTarget(target: Target, targetId: string): Promise<T[]>;
|
|
110
|
+
/** Tags for many targets in ONE round trip. Only targets with at least one tag appear in the map. */
|
|
111
|
+
tagsForTargets(target: Target, targetIds: string[]): Promise<Map<string, T[]>>;
|
|
112
|
+
/**
|
|
113
|
+
* Items per tag per target type, for EVERY tag of the workspace (or only `ids`): a tag with no links appears with zero for every target
|
|
114
|
+
* type. One grouped query per target type in a real adapter, not one per tag. An EMPTY `ids` asks for nothing: it returns an empty map.
|
|
115
|
+
*/
|
|
116
|
+
counts(ids?: string[]): Promise<Map<string, Record<Target, number>>>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type TagChangePlan = {
|
|
120
|
+
ok: true;
|
|
121
|
+
change: TagLinkChange;
|
|
122
|
+
} | {
|
|
123
|
+
ok: false;
|
|
124
|
+
reason: 'too-many-changes';
|
|
125
|
+
count: number;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* An id in both lists stays ADDED (the user's last word is "keep it"): it is dropped from `remove`. The count that is capped is the number
|
|
129
|
+
* of distinct changes after that (adds plus removes).
|
|
130
|
+
*/
|
|
131
|
+
declare function planTagChanges(change: TagLinkChange, maxChanges: number): TagChangePlan;
|
|
132
|
+
|
|
133
|
+
/** Why a call did not do what was asked, with the context an app needs for its message. */
|
|
134
|
+
type TagFailure = {
|
|
135
|
+
reason: 'invalid-name';
|
|
136
|
+
problem: 'empty' | 'too-long';
|
|
137
|
+
length: number;
|
|
138
|
+
} | {
|
|
139
|
+
reason: 'invalid-color';
|
|
140
|
+
color: string;
|
|
141
|
+
} | {
|
|
142
|
+
reason: 'duplicate';
|
|
143
|
+
} | {
|
|
144
|
+
reason: 'limit';
|
|
145
|
+
} | {
|
|
146
|
+
reason: 'pin-limit';
|
|
147
|
+
} | {
|
|
148
|
+
reason: 'not-found';
|
|
149
|
+
} | {
|
|
150
|
+
reason: 'target-gone';
|
|
151
|
+
} | {
|
|
152
|
+
reason: 'tag-gone';
|
|
153
|
+
} | {
|
|
154
|
+
reason: 'too-many-changes';
|
|
155
|
+
count: number;
|
|
156
|
+
};
|
|
157
|
+
type TagResult<V> = ({
|
|
158
|
+
ok: true;
|
|
159
|
+
} & V) | ({
|
|
160
|
+
ok: false;
|
|
161
|
+
} & TagFailure);
|
|
162
|
+
interface TagOption {
|
|
163
|
+
id: string;
|
|
164
|
+
name: string;
|
|
165
|
+
color: string;
|
|
166
|
+
}
|
|
167
|
+
interface TagService<T extends TagRecord, Target extends string> {
|
|
168
|
+
/** Every tag, sorted by name. */
|
|
169
|
+
list(): Promise<T[]>;
|
|
170
|
+
/** Just what a picker needs, sorted by name. */
|
|
171
|
+
options(): Promise<TagOption[]>;
|
|
172
|
+
/** Pinned tags (at most `maxPinned`), sorted by name. */
|
|
173
|
+
pinned(): Promise<T[]>;
|
|
174
|
+
get(id: string): Promise<TagResult<{
|
|
175
|
+
tag: T;
|
|
176
|
+
}>>;
|
|
177
|
+
create(input: {
|
|
178
|
+
name: string;
|
|
179
|
+
color?: string;
|
|
180
|
+
}): Promise<TagResult<{
|
|
181
|
+
tag: T;
|
|
182
|
+
}>>;
|
|
183
|
+
/** The existing tag when the name is already taken (any case), else a new one. */
|
|
184
|
+
findOrCreate(name: string): Promise<TagResult<{
|
|
185
|
+
tag: T;
|
|
186
|
+
created: boolean;
|
|
187
|
+
}>>;
|
|
188
|
+
update(id: string, input: {
|
|
189
|
+
name?: string;
|
|
190
|
+
color?: string;
|
|
191
|
+
pinned?: boolean;
|
|
192
|
+
}): Promise<TagResult<{
|
|
193
|
+
before: T;
|
|
194
|
+
after: T;
|
|
195
|
+
}>>;
|
|
196
|
+
remove(id: string): Promise<TagResult<{
|
|
197
|
+
tag: T;
|
|
198
|
+
}>>;
|
|
199
|
+
/** Applies ONLY what the user changed. An empty change succeeds without touching the store (not even to check the item exists). */
|
|
200
|
+
applyChanges(target: Target, targetId: string, change: {
|
|
201
|
+
add: string[];
|
|
202
|
+
remove: string[];
|
|
203
|
+
}, actorId: string): Promise<TagResult<{
|
|
204
|
+
added: string[];
|
|
205
|
+
removed: string[];
|
|
206
|
+
skipped: string[];
|
|
207
|
+
}>>;
|
|
208
|
+
forTarget(target: Target, targetId: string): Promise<TagResult<{
|
|
209
|
+
tags: T[];
|
|
210
|
+
}>>;
|
|
211
|
+
forTargets(target: Target, targetIds: string[]): Promise<Map<string, T[]>>;
|
|
212
|
+
counts(ids?: string[]): Promise<Map<string, Record<Target, number>>>;
|
|
213
|
+
}
|
|
214
|
+
declare function createTagService<T extends TagRecord, Target extends string>(store: TagStore<T, Target>, suppliedLimits?: TagLimits): TagService<T, Target>;
|
|
215
|
+
|
|
216
|
+
interface TagContractRunner {
|
|
217
|
+
describe: (name: string, body: () => void) => void;
|
|
218
|
+
it: ((name: string, body: () => Promise<void>) => void) & {
|
|
219
|
+
skip?: (name: string, body: () => Promise<void>) => void;
|
|
220
|
+
};
|
|
221
|
+
expect: (value: unknown) => {
|
|
222
|
+
toBe(expected: unknown): void;
|
|
223
|
+
toEqual(expected: unknown): void;
|
|
224
|
+
toBeNull(): void;
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
interface TagStoreHarness<Target extends string = string> {
|
|
228
|
+
/** The taggable kinds under test. The suite REQUIRES at least three (it throws otherwise); four or more prove that a new kind needs no change to the logic. */
|
|
229
|
+
targetTypes: readonly Target[];
|
|
230
|
+
/** A fresh, empty workspace id. Every test uses its own, so tests never see each other's rows. */
|
|
231
|
+
newWorkspace(): Promise<string>;
|
|
232
|
+
/** The store for one workspace. Called repeatedly for the same id: it must give the same data. */
|
|
233
|
+
storeFor(workspace: string): TagStore<TagRecord, Target>;
|
|
234
|
+
/** Creates a real taggable thing (a person, a task ...) in the workspace and returns its id. */
|
|
235
|
+
addTarget(workspace: string, type: Target): Promise<string>;
|
|
236
|
+
/**
|
|
237
|
+
* OPTIONAL. Creates a target of `type` with exactly this id, so a person and a task can share one id (as they do when ids are per-table
|
|
238
|
+
* integers). Offer it if your storage allows; the tests that need it are skipped (or reported as skipped) without it.
|
|
239
|
+
*/
|
|
240
|
+
addTargetWithId?(workspace: string, type: Target, id: string): Promise<string>;
|
|
241
|
+
/**
|
|
242
|
+
* Deletes it with everything that points at it. NOTE: the contract also passes ids that do not exist, such as 'missing', 'ghost-1' or
|
|
243
|
+
* 'nothing', for tags and targets: an adapter with uuid or integer id columns must answer them as "not found" instead of failing the cast.
|
|
244
|
+
*/
|
|
245
|
+
removeTarget(workspace: string, type: Target, id: string): Promise<void>;
|
|
246
|
+
/**
|
|
247
|
+
* OPTIONAL test seam. Arms a ONE-SHOT hook that the next `applyLinks` runs after its existence checks and before its write, so a test can
|
|
248
|
+
* delete a tag or a target at exactly the dangerous moment. Without it the two delete-while-applying checks are shown as skipped when your
|
|
249
|
+
* runner has `it.skip`; if it does not, they run and fail, so supply the hook.
|
|
250
|
+
*/
|
|
251
|
+
armBeforeLinkWrite?(hook: () => Promise<void>): void;
|
|
252
|
+
/** Removes everything the tests created. */
|
|
253
|
+
cleanup(): Promise<void>;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface MemoryTagWorldOptions<Target extends string> {
|
|
257
|
+
targetTypes: readonly Target[];
|
|
258
|
+
/** Default true. False removes the one-at-a-time chain so races become visible (for proving the contract's concurrency tests). */
|
|
259
|
+
serialise?: boolean;
|
|
260
|
+
}
|
|
261
|
+
interface MemoryTagWorld<Target extends string> {
|
|
262
|
+
store(workspace: string): TagStore<TagRecord, Target>;
|
|
263
|
+
/** Registers a taggable thing (a person, a task ...) and returns its id. */
|
|
264
|
+
addTarget(workspace: string, type: Target, id?: string): string;
|
|
265
|
+
/** Removes a target and every link to it. */
|
|
266
|
+
removeTarget(workspace: string, type: Target, id: string): Promise<void>;
|
|
267
|
+
/** Arms a ONE-SHOT hook run by the next `applyLinks` IN THIS WORLD (any workspace) between its existence check and its write (to delete a tag or target mid-flight). */
|
|
268
|
+
armBeforeLinkWrite(hook: () => Promise<void>): void;
|
|
269
|
+
/** Who linked it (the `actorId` passed to `applyLinks`), or undefined when there is no such link. */
|
|
270
|
+
actorOf(workspace: string, type: Target, targetId: string, tagId: string): string | undefined;
|
|
271
|
+
}
|
|
272
|
+
declare function createMemoryTagWorld<Target extends string>(options: MemoryTagWorldOptions<Target>): MemoryTagWorld<Target>;
|
|
273
|
+
/** The contract harness around a fresh in-memory world: what this package runs its own contract with, and a starting point for an app's fake. */
|
|
274
|
+
declare function createMemoryTagHarness<Target extends string>(targetTypes: readonly Target[], options?: {
|
|
275
|
+
serialise?: boolean;
|
|
276
|
+
}): TagStoreHarness<Target> & {
|
|
277
|
+
world: MemoryTagWorld<Target>;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
declare function defineTagStoreContract<Target extends string>(api: TagContractRunner, harness: TagStoreHarness<Target>): void;
|
|
281
|
+
|
|
282
|
+
export { type MemoryTagWorld, type MemoryTagWorldOptions, type TagApplyResult, type TagChangePlan, type TagContractRunner, type TagCreateLimits, type TagCreateResult, type TagFailure, type TagLinkChange, type TagOption, type TagRecord, type TagRemoveResult, type TagResult, type TagService, type TagStore, type TagStoreHarness, type TagUpdateLimits, type TagUpdatePatch, type TagUpdateResult, createMemoryTagHarness, createMemoryTagWorld, createTagService, defineTagStoreContract, planTagChanges };
|