@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.
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. A store interface, service and contract suite are planned for a later release on a separate server entry; until a second product has used them, the interface is experimental.
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,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 };
@@ -1,16 +1,5 @@
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>;
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 { DEFAULT_TAG_LIMITS, type RebasedSelection, TAG_COLORS, type TagChoice, type TagColor, type TagLimits, type TagNameCheck, type TagSnapshot, checkTagName, cleanTagName, describeTagChanges, isTagColor, mergeTagOptions, rebaseSelection, resolveTagColor, tagNameKey, tagSetKey };
67
+ export { type RebasedSelection, type TagChoice, TagColor, type TagNameCheck, type TagSnapshot, checkTagName, cleanTagName, describeTagChanges, isTagColor, mergeTagOptions, rebaseSelection, resolveTagColor, tagNameKey, tagSetKey };
@@ -1,56 +1,16 @@
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
- }
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,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
+ };