@ordinatio/entities 1.2.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.
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. 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.
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`). On a further entry, `@ordinatio/entities/tags/prisma` (also EXPERIMENTAL): `createPrismaTagStore(config)`, a ready-made `TagStore` for a Prisma client on Postgres, so an app writes configuration instead of storage code. It holds the generic storage rules: a per-workspace advisory lock around the two limits (the lock key prefix is configurable, so an app can keep an older key), lock waits that THROW on timeout in create, pin and update (a remove and a link write start no explicit lock of their own, so they wait only on locks other transactions hold, bounded only if those transactions are), `FOR UPDATE` on the tag row in an update (which briefly serialises with the key-share lock a concurrent link insert takes on the same tag), `createManyAndReturn` and `DELETE ... RETURNING` so `added` and `removed` come from rows really written, and foreign-key-abort handling (a link to a tag or item deleted a moment ago becomes `tagGone` or `targetGone`, decided by a fresh read after the aborted transaction). The configuration names your client (as a getter, called each time the store needs the client), the tag model and table, the workspace column, and per taggable kind the join model, table and columns and the item model; every name that reaches SQL is checked once and double-quoted, values are always bound. It needs Prisma 5.14 or later and unique and foreign-key constraints in your database (listed at the top of its `types.ts`) and does not import `@prisma/client`. **Prove it on YOUR database** by running `defineTagStoreContract` against it with a harness of your own (with at least ten connections available): the contract, not the unit tests of this package, is what shows the SQL, the locks and the foreign keys behave. 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,60 @@
1
+ import { T as TagRecord, a as TagStore } from '../../types-C81I1Teg.mjs';
2
+
3
+ /** One taggable kind (a person, a task, a message ...). Names are Prisma model delegates, SQL tables and columns of YOUR schema. */
4
+ interface PrismaTagTarget {
5
+ /** Prisma delegate of the join model, e.g. `contactTag`. */
6
+ linkModel: string;
7
+ /** SQL table of the join model, e.g. `ContactTag` (used in the one raw `DELETE ... RETURNING`). */
8
+ linkTable: string;
9
+ /** Column AND Prisma field on the join model that points at the item, e.g. `contactId`. */
10
+ targetColumn: string;
11
+ /** Prisma delegate of the item model, e.g. `contact`. */
12
+ targetModel: string;
13
+ /** The item model's workspace column. Defaults to the tag's. */
14
+ targetWorkspaceColumn?: string;
15
+ }
16
+ interface PrismaTagStoreConfig<Target extends string> {
17
+ /** Called each time the store needs the client (never at import), so a test can swap in a client with a bigger pool. */
18
+ client: () => PrismaLikeClient;
19
+ /** The workspace (organization, account, tenant) this store is built for. Every query is scoped by it. */
20
+ workspaceId: string;
21
+ /** Prisma delegate of the tag model, e.g. `tag`. */
22
+ tagModel: string;
23
+ /** SQL table of the tag model, e.g. `Tag` (used in the `SELECT ... FOR UPDATE`). */
24
+ tagTable: string;
25
+ /** Workspace column on the tag model, e.g. `organizationId`. */
26
+ workspaceColumn: string;
27
+ targets: Record<Target, PrismaTagTarget>;
28
+ /** Relation field on a join model to the tag. Default `tag`. */
29
+ tagRelation?: string;
30
+ /** Column AND field on a join model for the tag id. Default `tagId`. */
31
+ tagIdColumn?: string;
32
+ /** Column AND field on a join model for who tagged it. Default `addedBy`. */
33
+ addedByColumn?: string;
34
+ /** The advisory lock is on `prefix + workspaceId` (hashed by Postgres). Default `tags:`. Keep an old app's value so old and new servers serialise. */
35
+ lockKeyPrefix?: string;
36
+ /** How long a transaction waits for any one lock before it throws (Postgres lock_timeout). Default 5000. */
37
+ lockWaitMs?: number;
38
+ /** Interactive transaction limits. Defaults: maxWait 10000, timeout 15000. */
39
+ transaction?: {
40
+ maxWait?: number;
41
+ timeout?: number;
42
+ };
43
+ /** TEST ONLY: runs inside `applyLinks` after the existence reads and before the write, holding NO row lock, so a test can delete a tag or item there. */
44
+ onBeforeLinkWrite?: () => Promise<void>;
45
+ }
46
+ /** The part of a transaction client the store uses. A real Prisma transaction client satisfies it; model delegates are looked up by name. */
47
+ interface PrismaLikeTx {
48
+ $executeRawUnsafe(query: string, ...values: unknown[]): Promise<unknown>;
49
+ $queryRawUnsafe(query: string, ...values: unknown[]): Promise<unknown>;
50
+ }
51
+ interface PrismaLikeClient extends PrismaLikeTx {
52
+ $transaction<R>(fn: (tx: PrismaLikeTx) => Promise<R>, options?: {
53
+ maxWait?: number;
54
+ timeout?: number;
55
+ }): Promise<R>;
56
+ }
57
+
58
+ declare function createPrismaTagStore<T extends TagRecord = TagRecord, Target extends string = string>(config: PrismaTagStoreConfig<Target>): TagStore<T, Target>;
59
+
60
+ export { type PrismaLikeClient, type PrismaLikeTx, type PrismaTagStoreConfig, type PrismaTagTarget, createPrismaTagStore };
@@ -0,0 +1,268 @@
1
+ // src/tags/prisma/identifiers.ts
2
+ var IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
+ var RESERVED = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype", "toString", "valueOf", "hasOwnProperty"]);
4
+ function assertIdentifier(value, what) {
5
+ if (typeof value !== "string" || !IDENTIFIER.test(value) || RESERVED.has(value)) {
6
+ throw new Error(`createPrismaTagStore: ${what} must be a plain identifier (letters, digits and underscores, not starting with a digit), got ${JSON.stringify(value)}`);
7
+ }
8
+ return value;
9
+ }
10
+ function quote(identifier) {
11
+ return `"${identifier}"`;
12
+ }
13
+
14
+ // src/tags/prisma/context.ts
15
+ function resolveTarget(kind, target, defaultWorkspaceColumn) {
16
+ const where = `targets.${kind}`;
17
+ return {
18
+ linkModel: assertIdentifier(target.linkModel, `${where}.linkModel`),
19
+ linkTable: assertIdentifier(target.linkTable, `${where}.linkTable`),
20
+ targetColumn: assertIdentifier(target.targetColumn, `${where}.targetColumn`),
21
+ targetModel: assertIdentifier(target.targetModel, `${where}.targetModel`),
22
+ targetWorkspaceColumn: assertIdentifier(target.targetWorkspaceColumn ?? defaultWorkspaceColumn, `${where}.targetWorkspaceColumn`)
23
+ };
24
+ }
25
+ function createContext(config) {
26
+ if (typeof config.client !== "function") throw new Error("createPrismaTagStore: client must be a function that returns the Prisma client");
27
+ if (typeof config.workspaceId !== "string" || config.workspaceId === "") throw new Error("createPrismaTagStore: workspaceId must be a non-empty string");
28
+ const workspaceColumn = assertIdentifier(config.workspaceColumn, "workspaceColumn");
29
+ const tagModel = assertIdentifier(config.tagModel, "tagModel");
30
+ const tagTable = assertIdentifier(config.tagTable, "tagTable");
31
+ const tagRelation = assertIdentifier(config.tagRelation ?? "tag", "tagRelation");
32
+ const tagIdColumn = assertIdentifier(config.tagIdColumn ?? "tagId", "tagIdColumn");
33
+ const addedByColumn = assertIdentifier(config.addedByColumn ?? "addedBy", "addedByColumn");
34
+ const lockKeyPrefix = config.lockKeyPrefix ?? "tags:";
35
+ const lockWaitMs = config.lockWaitMs ?? 5e3;
36
+ if (!Number.isInteger(lockWaitMs) || lockWaitMs <= 0) throw new Error("createPrismaTagStore: lockWaitMs must be a positive integer");
37
+ const kinds = Object.keys(config.targets);
38
+ if (kinds.length === 0) throw new Error("createPrismaTagStore: targets must name at least one taggable kind");
39
+ const targets = new Map(kinds.map((kind) => [kind, resolveTarget(kind, config.targets[kind], workspaceColumn)]));
40
+ const lockKey = `${lockKeyPrefix}${config.workspaceId}`;
41
+ const txOptions = { maxWait: config.transaction?.maxWait ?? 1e4, timeout: config.transaction?.timeout ?? 15e3 };
42
+ const model = (client, name) => {
43
+ const found = client[name];
44
+ if (typeof found !== "object" || found === null) throw new Error(`createPrismaTagStore: the Prisma client has no model "${name}" (check the configuration)`);
45
+ return found;
46
+ };
47
+ return {
48
+ kinds,
49
+ workspaceId: config.workspaceId,
50
+ workspaceColumn,
51
+ tagRelation,
52
+ tagIdColumn,
53
+ addedByColumn,
54
+ sql: { tagTable: quote(tagTable), workspaceColumn: quote(workspaceColumn) },
55
+ target: (kind) => {
56
+ const found = targets.get(kind);
57
+ if (!found) throw new Error(`createPrismaTagStore: "${kind}" is not a configured taggable kind (configured: ${[...targets.keys()].join(", ")})`);
58
+ return found;
59
+ },
60
+ txOptions,
61
+ beforeLinkWrite: config.onBeforeLinkWrite,
62
+ client: config.client,
63
+ model,
64
+ tags: (client) => model(client, tagModel),
65
+ link: (client, kind) => model(client, targets.get(kind).linkModel),
66
+ tagWhere: (extra = {}) => ({ ...extra, [workspaceColumn]: config.workspaceId }),
67
+ async limitLockWait(tx) {
68
+ await tx.$executeRawUnsafe(`SELECT set_config('lock_timeout', $1, true)`, `${lockWaitMs}ms`);
69
+ },
70
+ async lockWorkspace(tx) {
71
+ await tx.$executeRawUnsafe(`SELECT pg_advisory_xact_lock(hashtext($1))`, lockKey);
72
+ }
73
+ };
74
+ }
75
+
76
+ // src/tags/prisma/errors.ts
77
+ function hasCode(error, code, depth) {
78
+ if (typeof error !== "object" || error === null || depth > 4) return false;
79
+ const fields = error;
80
+ if (fields.code === code) return true;
81
+ return ["cause", "meta", "originalError", "driverAdapterError"].some((key) => hasCode(fields[key], code, depth + 1));
82
+ }
83
+ var isUniqueViolation = (error) => hasCode(error, "P2002", 0);
84
+ var isForeignKeyViolation = (error) => hasCode(error, "P2003", 0);
85
+
86
+ // src/tags/prisma/links.ts
87
+ function createLinks(context, targetExists) {
88
+ return {
89
+ async applyLinks(kind, targetId, change, actorId) {
90
+ if (!await targetExists(kind, targetId)) return { kind: "targetGone" };
91
+ const target = context.target(kind);
92
+ const adding = [...new Set(change.add)];
93
+ const removing = [...new Set(change.remove)];
94
+ const asked = [.../* @__PURE__ */ new Set([...adding, ...removing])];
95
+ const known = /* @__PURE__ */ new Set();
96
+ if (asked.length > 0) {
97
+ const rows = await context.tags(context.client()).findMany({ where: context.tagWhere({ id: { in: asked } }), select: { id: true } });
98
+ for (const row of rows) known.add(row.id);
99
+ }
100
+ const attachable = adding.filter((id) => known.has(id));
101
+ const skipped = adding.filter((id) => !known.has(id));
102
+ const removable = removing.filter((id) => known.has(id));
103
+ if (context.beforeLinkWrite) await context.beforeLinkWrite();
104
+ if (attachable.length === 0 && removable.length === 0) return { kind: "applied", added: [], removed: [], skipped };
105
+ try {
106
+ const result = await context.client().$transaction(async (tx) => {
107
+ let added = [];
108
+ if (attachable.length > 0) {
109
+ const rows = await context.link(tx, kind).createManyAndReturn({
110
+ data: attachable.map((tagId) => ({ [target.targetColumn]: targetId, [context.tagIdColumn]: tagId, [context.addedByColumn]: actorId })),
111
+ skipDuplicates: true,
112
+ select: { [context.tagIdColumn]: true }
113
+ });
114
+ added = rows.map((row) => row[context.tagIdColumn]);
115
+ }
116
+ let removed = [];
117
+ if (removable.length > 0) {
118
+ const rows = await tx.$queryRawUnsafe(
119
+ `DELETE FROM ${quote(target.linkTable)} WHERE ${quote(target.targetColumn)} = $1 AND ${quote(context.tagIdColumn)} = ANY($2::text[]) RETURNING ${quote(context.tagIdColumn)} AS "tagId"`,
120
+ targetId,
121
+ removable
122
+ );
123
+ removed = rows.map((row) => row.tagId);
124
+ }
125
+ return { kind: "applied", added, removed, skipped };
126
+ }, context.txOptions);
127
+ if (result.added.length === 0 && result.removed.length === 0 && (attachable.length > 0 || removable.length > 0) && !await targetExists(kind, targetId)) return { kind: "targetGone" };
128
+ return result;
129
+ } catch (error) {
130
+ if (!isForeignKeyViolation(error)) throw error;
131
+ return await targetExists(kind, targetId) ? { kind: "tagGone" } : { kind: "targetGone" };
132
+ }
133
+ }
134
+ };
135
+ }
136
+
137
+ // src/tags/prisma/reads.ts
138
+ function createReads(context) {
139
+ const { tagWhere } = context;
140
+ const db = () => context.client();
141
+ async function tagsOnTargets(kind, targetIds) {
142
+ if (targetIds.length === 0) return [];
143
+ const { targetColumn } = context.target(kind);
144
+ const rows = await context.link(db(), kind).findMany({
145
+ where: { [targetColumn]: { in: targetIds }, [context.tagRelation]: tagWhere() },
146
+ include: { [context.tagRelation]: true }
147
+ });
148
+ return rows.map((row) => ({ targetId: row[targetColumn], tag: row[context.tagRelation] }));
149
+ }
150
+ return {
151
+ async list() {
152
+ return await context.tags(db()).findMany({ where: tagWhere() });
153
+ },
154
+ async listPinned(limit) {
155
+ if (limit <= 0) return [];
156
+ return await context.tags(db()).findMany({ where: tagWhere({ pinned: true }), orderBy: [{ name: "asc" }, { id: "asc" }], take: limit });
157
+ },
158
+ async getMany(ids) {
159
+ if (ids.length === 0) return [];
160
+ return await context.tags(db()).findMany({ where: tagWhere({ id: { in: [...new Set(ids)] } }) });
161
+ },
162
+ async findByKey(nameKey) {
163
+ return await context.tags(db()).findFirst({ where: tagWhere({ nameKey }) });
164
+ },
165
+ async get(id) {
166
+ return await context.tags(db()).findFirst({ where: tagWhere({ id }) });
167
+ },
168
+ async targetExists(kind, targetId) {
169
+ const target = context.target(kind);
170
+ const row = await context.model(db(), target.targetModel).findFirst({ where: { id: targetId, [target.targetWorkspaceColumn]: context.workspaceId }, select: { id: true } });
171
+ return row !== null && row !== void 0;
172
+ },
173
+ async counts(ids) {
174
+ if (ids && ids.length === 0) return /* @__PURE__ */ new Map();
175
+ const tags = await context.tags(db()).findMany({ where: tagWhere(ids ? { id: { in: [...new Set(ids)] } } : {}), select: { id: true } });
176
+ const result = /* @__PURE__ */ new Map();
177
+ for (const { id } of tags) result.set(id, Object.fromEntries(context.kinds.map((kind) => [kind, 0])));
178
+ if (tags.length === 0) return result;
179
+ for (const kind of context.kinds) {
180
+ const grouped = await context.link(db(), kind).groupBy({ by: [context.tagIdColumn], where: { [context.tagIdColumn]: { in: tags.map((tag) => tag.id) } }, _count: { _all: true } });
181
+ for (const row of grouped) {
182
+ const entry = result.get(row[context.tagIdColumn]);
183
+ if (entry) entry[kind] = row._count._all;
184
+ }
185
+ }
186
+ return result;
187
+ },
188
+ async tagsForTarget(kind, targetId) {
189
+ return (await tagsOnTargets(kind, [targetId])).map((row) => row.tag);
190
+ },
191
+ async tagsForTargets(kind, targetIds) {
192
+ const result = /* @__PURE__ */ new Map();
193
+ for (const { targetId, tag } of await tagsOnTargets(kind, [...new Set(targetIds)])) {
194
+ const list = result.get(targetId) ?? [];
195
+ list.push(tag);
196
+ result.set(targetId, list);
197
+ }
198
+ return result;
199
+ }
200
+ };
201
+ }
202
+
203
+ // src/tags/prisma/writes.ts
204
+ function createWrites(context) {
205
+ const { tagWhere } = context;
206
+ const db = () => context.client();
207
+ return {
208
+ async create(input, limits) {
209
+ try {
210
+ return await db().$transaction(async (tx) => {
211
+ await context.limitLockWait(tx);
212
+ await context.lockWorkspace(tx);
213
+ const tags = context.tags(tx);
214
+ if (await tags.count({ where: tagWhere() }) >= limits.maxTags) return { kind: "limit" };
215
+ if (await tags.findFirst({ where: tagWhere({ nameKey: input.nameKey }), select: { id: true } })) return { kind: "duplicate" };
216
+ const tag = await tags.create({ data: { ...tagWhere(), name: input.name, nameKey: input.nameKey, color: input.color } });
217
+ return { kind: "created", tag };
218
+ }, context.txOptions);
219
+ } catch (error) {
220
+ if (isUniqueViolation(error)) return { kind: "duplicate" };
221
+ throw error;
222
+ }
223
+ },
224
+ async update(id, patch, limits) {
225
+ const wantsPin = patch.pinned === true;
226
+ try {
227
+ return await db().$transaction(async (tx) => {
228
+ await context.limitLockWait(tx);
229
+ if (wantsPin) await context.lockWorkspace(tx);
230
+ const locked = await tx.$queryRawUnsafe(`SELECT "id" FROM ${context.sql.tagTable} WHERE "id" = $1 AND ${context.sql.workspaceColumn} = $2 FOR UPDATE`, id, context.workspaceId);
231
+ const tags = context.tags(tx);
232
+ const before = locked.length === 0 ? null : await tags.findFirst({ where: tagWhere({ id }) });
233
+ if (!before) return { kind: "notFound" };
234
+ if (patch.rename === void 0 && patch.color === void 0 && patch.pinned === void 0) return { kind: "updated", before, after: { ...before } };
235
+ if (wantsPin && !before.pinned && await tags.count({ where: tagWhere({ pinned: true }) }) >= limits.maxPinned) return { kind: "pinLimit" };
236
+ if (patch.rename && await tags.findFirst({ where: tagWhere({ nameKey: patch.rename.nameKey, NOT: { id } }), select: { id: true } })) return { kind: "duplicate" };
237
+ const data = {};
238
+ if (patch.rename) Object.assign(data, { name: patch.rename.name, nameKey: patch.rename.nameKey });
239
+ if (patch.color !== void 0) data.color = patch.color;
240
+ if (patch.pinned !== void 0) data.pinned = patch.pinned;
241
+ await tags.updateMany({ where: tagWhere({ id }), data });
242
+ const after = await tags.findFirst({ where: tagWhere({ id }) });
243
+ return { kind: "updated", before, after };
244
+ }, context.txOptions);
245
+ } catch (error) {
246
+ if (isUniqueViolation(error)) return { kind: "duplicate" };
247
+ throw error;
248
+ }
249
+ },
250
+ async remove(id) {
251
+ const tags = context.tags(db());
252
+ const tag = await tags.findFirst({ where: tagWhere({ id }) });
253
+ if (!tag) return { kind: "notFound" };
254
+ const { count } = await tags.deleteMany({ where: tagWhere({ id }) });
255
+ return count === 0 ? { kind: "notFound" } : { kind: "removed", tag };
256
+ }
257
+ };
258
+ }
259
+
260
+ // src/tags/prisma/index.ts
261
+ function createPrismaTagStore(config) {
262
+ const context = createContext(config);
263
+ const reads = createReads(context);
264
+ return { ...reads, ...createWrites(context), ...createLinks(context, reads.targetExists) };
265
+ }
266
+ export {
267
+ createPrismaTagStore
268
+ };
@@ -1,121 +1,7 @@
1
+ import { b as TagLinkChange, T as TagRecord, a as TagStore } from '../../types-C81I1Teg.mjs';
2
+ export { c as TagApplyResult, d as TagCreateLimits, e as TagCreateResult, f as TagRemoveResult, g as TagUpdateLimits, h as TagUpdatePatch, i as TagUpdateResult } from '../../types-C81I1Teg.mjs';
1
3
  import { b as TagLimits } from '../../constants-DSRJ-l7Z.mjs';
2
4
 
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
5
  type TagChangePlan = {
120
6
  ok: true;
121
7
  change: TagLinkChange;
@@ -279,4 +165,4 @@ declare function createMemoryTagHarness<Target extends string>(targetTypes: read
279
165
 
280
166
  declare function defineTagStoreContract<Target extends string>(api: TagContractRunner, harness: TagStoreHarness<Target>): void;
281
167
 
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 };
168
+ export { type MemoryTagWorld, type MemoryTagWorldOptions, type TagChangePlan, type TagContractRunner, type TagFailure, TagLinkChange, type TagOption, TagRecord, type TagResult, type TagService, TagStore, type TagStoreHarness, createMemoryTagHarness, createMemoryTagWorld, createTagService, defineTagStoreContract, planTagChanges };
@@ -878,6 +878,17 @@ function defineTagStoreScopingContract(kit) {
878
878
  expect(await store.applyLinks(first, target, { add: [a.id], remove: [] }, "u")).toEqual({ kind: "targetGone" });
879
879
  expect((await store.counts()).get(a.id)?.[first]).toBe(0);
880
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
+ });
881
892
  });
882
893
  }
883
894
 
@@ -0,0 +1,117 @@
1
+ /** The fields the rules need. An adapter may return a wider row (createdAt ...): the store is generic over it. */
2
+ interface TagRecord {
3
+ id: string;
4
+ name: string;
5
+ /** The case-insensitive identity of the name (unique per workspace): see `tagNameKey` in `@ordinatio/entities/tags`. */
6
+ nameKey: string;
7
+ color: string;
8
+ pinned: boolean;
9
+ }
10
+ /** Named object types so a future limit is an additive field, not a signature change. */
11
+ interface TagCreateLimits {
12
+ maxTags: number;
13
+ }
14
+ interface TagUpdateLimits {
15
+ maxPinned: number;
16
+ }
17
+ type TagCreateResult<T extends TagRecord> = {
18
+ kind: 'created';
19
+ tag: T;
20
+ } | {
21
+ kind: 'duplicate';
22
+ } | {
23
+ kind: 'limit';
24
+ };
25
+ interface TagUpdatePatch {
26
+ /** Name and key travel TOGETHER so they cannot disagree. */
27
+ rename?: {
28
+ name: string;
29
+ nameKey: string;
30
+ };
31
+ color?: string;
32
+ pinned?: boolean;
33
+ }
34
+ type TagUpdateResult<T extends TagRecord> = {
35
+ kind: 'updated';
36
+ before: T;
37
+ after: T;
38
+ } | {
39
+ kind: 'notFound';
40
+ } | {
41
+ kind: 'pinLimit';
42
+ } | {
43
+ kind: 'duplicate';
44
+ };
45
+ type TagRemoveResult<T extends TagRecord> = {
46
+ kind: 'removed';
47
+ tag: T;
48
+ } | {
49
+ kind: 'notFound';
50
+ };
51
+ interface TagLinkChange {
52
+ add: string[];
53
+ remove: string[];
54
+ }
55
+ type TagApplyResult = {
56
+ kind: 'applied';
57
+ added: string[];
58
+ removed: string[];
59
+ skipped: string[];
60
+ } | {
61
+ kind: 'targetGone';
62
+ } | {
63
+ kind: 'tagGone';
64
+ };
65
+ interface TagStore<T extends TagRecord = TagRecord, Target extends string = string> {
66
+ /** Every tag of the workspace. ORDER IS UNSPECIFIED: the service sorts. */
67
+ list(): Promise<T[]>;
68
+ /**
69
+ * 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
70
+ * than `limit` the SAME ones should come back each time (for example by name), or the bookmarks would change from page to page.
71
+ */
72
+ listPinned(limit: number): Promise<T[]>;
73
+ /** The tags of THIS workspace among `ids`; foreign or deleted ids are simply absent. */
74
+ getMany(ids: string[]): Promise<T[]>;
75
+ findByKey(nameKey: string): Promise<T | null>;
76
+ get(id: string): Promise<T | null>;
77
+ /**
78
+ * ATOMIC: the limit is enforced together with the insert, so simultaneous creates can never overshoot it. Precedence: `limit` wins over
79
+ * `duplicate` (a duplicate name when the workspace is full is `limit`).
80
+ */
81
+ create(input: {
82
+ name: string;
83
+ nameKey: string;
84
+ color: string;
85
+ }, limits: TagCreateLimits): Promise<TagCreateResult<T>>;
86
+ /**
87
+ * ATOMIC. Precedence: `notFound`, then `pinLimit`, then `duplicate`. Pinning a tag that is ALREADY pinned never counts against the limit
88
+ * (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
89
+ * not a duplicate. An empty patch is a no-op that returns `updated` with `before` equal to `after`.
90
+ * `before` is read in the same transaction as the write, so `before`/`after` are consistent under concurrent edits.
91
+ */
92
+ update(id: string, patch: TagUpdatePatch, limits: TagUpdateLimits): Promise<TagUpdateResult<T>>;
93
+ /** The tag's links go with it. Ten parallel removes of one tag give exactly one `removed`. */
94
+ remove(id: string): Promise<TagRemoveResult<T>>;
95
+ targetExists(target: Target, targetId: string): Promise<boolean>;
96
+ /**
97
+ * One transaction. Frozen result shapes:
98
+ * - `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
99
+ * tags are IGNORED silently (never reported, never touched). An `add` that is already linked is in neither `added` nor `skipped`.
100
+ * - A tag that existed at the start but is gone when the write happens: `tagGone`, nothing applied. A target that vanished: `targetGone`.
101
+ * - `added` / `removed` come from rows the database REALLY inserted / deleted, so concurrent saves never double-report one change.
102
+ * An id in BOTH `add` and `remove` is undefined behaviour: the service never sends it (`planTagChanges` drops it from `remove`).
103
+ * `actorId` is stored with each new link (who tagged it); it is opaque to this package.
104
+ */
105
+ applyLinks(target: Target, targetId: string, change: TagLinkChange, actorId: string): Promise<TagApplyResult>;
106
+ /** Tags on one target. Order unspecified. */
107
+ tagsForTarget(target: Target, targetId: string): Promise<T[]>;
108
+ /** Tags for many targets in ONE round trip. Only targets with at least one tag appear in the map. */
109
+ tagsForTargets(target: Target, targetIds: string[]): Promise<Map<string, T[]>>;
110
+ /**
111
+ * 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
112
+ * 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.
113
+ */
114
+ counts(ids?: string[]): Promise<Map<string, Record<Target, number>>>;
115
+ }
116
+
117
+ export type { TagRecord as T, TagStore as a, TagLinkChange as b, TagApplyResult as c, TagCreateLimits as d, TagCreateResult as e, TagRemoveResult as f, TagUpdateLimits as g, TagUpdatePatch as h, TagUpdateResult as i };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ordinatio/entities",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "Entity knowledge, agent intelligence, notes, and contacts",
@@ -24,6 +24,11 @@
24
24
  "types": "./dist/tags/server/index.d.mts",
25
25
  "import": "./dist/tags/server/index.mjs",
26
26
  "default": "./dist/tags/server/index.mjs"
27
+ },
28
+ "./tags/prisma": {
29
+ "types": "./dist/tags/prisma/index.d.mts",
30
+ "import": "./dist/tags/prisma/index.mjs",
31
+ "default": "./dist/tags/prisma/index.mjs"
27
32
  }
28
33
  },
29
34
  "publishConfig": {
@@ -54,7 +59,7 @@
54
59
  }
55
60
  },
56
61
  "scripts": {
57
- "build": "tsup src/index.ts src/tags/index.ts src/tags/server/index.ts --format esm --dts --clean",
62
+ "build": "tsup src/index.ts src/tags/index.ts src/tags/server/index.ts src/tags/prisma/index.ts --format esm --dts --clean",
58
63
  "test": "vitest",
59
64
  "test:run": "vitest run"
60
65
  }