@gigamusic/admin 2.0.3 → 3.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gigamusic/admin",
3
- "version": "2.0.3",
3
+ "version": "3.0.0",
4
4
  "description": "Server-only admin API handler factories for the gigamusic platform (releases CRUD, tracks, uploads, settings, links, orders).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,10 +24,10 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "zod": "^3.24.1",
27
- "@gigamusic/core": "1.0.0",
28
- "@gigamusic/storage": "1.0.0",
29
- "@gigamusic/db": "2.0.1",
30
- "@gigamusic/audio": "1.0.0"
27
+ "@gigamusic/db": "3.0.0",
28
+ "@gigamusic/audio": "3.0.0",
29
+ "@gigamusic/storage": "3.0.0",
30
+ "@gigamusic/core": "3.0.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^22.10.5",
@@ -1,32 +1,46 @@
1
- import type { AdminDeps, RouteHandler } from "../lib/types";
2
- import type { LinkInput } from "@gigamusic/db";
1
+ import { z } from "zod";
2
+ import type {
3
+ DefaultTables,
4
+ LinkInput,
5
+ Queries,
6
+ QueryTables,
7
+ } from "@gigamusic/db";
8
+ import type { AdminLogger, RouteHandler } from "../lib/types";
3
9
  import { badRequest, json, notFound } from "../lib/responses";
4
10
 
5
- interface LinkBody {
6
- id?: unknown;
7
- title?: unknown;
8
- url?: unknown;
9
- position?: unknown;
10
- isVisible?: unknown;
11
- showOnHero?: unknown;
12
- }
11
+ /**
12
+ * Base validation schema for a global `/links` page entry. Consumers extend
13
+ * this to validate extras (e.g. an internal flag) and pass it as `inputSchema`.
14
+ */
15
+ export const LinkInputSchema = z.object({
16
+ id: z.number().int().optional(),
17
+ title: z.string().trim().min(1),
18
+ url: z.string().trim().min(1),
19
+ position: z.number().int().nonnegative().optional(),
20
+ isVisible: z.boolean().optional(),
21
+ showOnHero: z.boolean().optional(),
22
+ });
13
23
 
14
24
  interface ReorderBody {
15
25
  orderedIds?: unknown;
16
26
  }
17
27
 
18
- function toLinkInput(body: LinkBody): LinkInput | null {
19
- const title = typeof body.title === "string" ? body.title : null;
20
- const url = typeof body.url === "string" ? body.url : null;
21
- if (!title || !url) return null;
22
- return {
23
- id: typeof body.id === "number" && Number.isFinite(body.id) ? body.id : undefined,
24
- title,
25
- url,
26
- position: typeof body.position === "number" ? body.position : undefined,
27
- isVisible: typeof body.isVisible === "boolean" ? body.isVisible : undefined,
28
- showOnHero: typeof body.showOnHero === "boolean" ? body.showOnHero : undefined,
29
- };
28
+ export interface LinksHandlerDeps<
29
+ T extends QueryTables = DefaultTables,
30
+
31
+ TInput extends z.ZodObject<any> = typeof LinkInputSchema,
32
+ > {
33
+ queries: Queries<T>;
34
+ logger?: AdminLogger;
35
+ /**
36
+ * Defaults to `LinkInputSchema`. Pass an `.extend()`ed schema to validate
37
+ * consumer extras on the wire. Note that `queries.upsertLink` only writes
38
+ * gigamusic-managed columns consumers persist their extras via an
39
+ * `afterWrite` hook (best-effort, not tx-atomic with the gigamusic write).
40
+ */
41
+ inputSchema?: TInput;
42
+ /** Invoked after `queries.upsertLink` resolves. Not wrapped in a transaction. */
43
+ afterWrite?: (linkId: number, input: z.infer<TInput>) => Promise<void>;
30
44
  }
31
45
 
32
46
  /**
@@ -41,27 +55,39 @@ function toLinkInput(body: LinkBody): LinkInput | null {
41
55
  * Auth: the package assumes the request has been gated by the consumer's
42
56
  * `proxy.ts` / middleware. These handlers don't verify a session.
43
57
  */
44
- export function createAdminLinksHandlers(
45
- deps: Pick<AdminDeps, "queries" | "logger">,
46
- ): {
58
+ export function createAdminLinksHandlers<
59
+ T extends QueryTables = DefaultTables,
60
+
61
+ TInput extends z.ZodObject<any> = typeof LinkInputSchema,
62
+ >(deps: LinksHandlerDeps<T, TInput>): {
47
63
  GET: RouteHandler;
48
64
  POST: RouteHandler;
49
65
  PUT: RouteHandler;
50
66
  DELETE: RouteHandler;
51
67
  } {
68
+ const inputSchema = (deps.inputSchema ?? LinkInputSchema) as TInput;
69
+
70
+ const runUpsert = async (input: z.infer<TInput>): Promise<unknown> => {
71
+ const link = await deps.queries.upsertLink(input as unknown as LinkInput);
72
+ if (deps.afterWrite) {
73
+ await deps.afterWrite((link as { id: number }).id, input);
74
+ }
75
+ return link;
76
+ };
77
+
52
78
  return {
53
79
  GET: async () => {
54
80
  return json(await deps.queries.listAllLinks());
55
81
  },
56
82
  POST: async (req) => {
57
- const body = (await safeJson(req)) as LinkBody;
58
- const input = toLinkInput(body);
59
- if (!input) return badRequest("title and url are required");
60
- const link = await deps.queries.upsertLink(input);
83
+ const body = (await safeJson(req)) as unknown;
84
+ const parsed = inputSchema.safeParse(body);
85
+ if (!parsed.success) return badRequest("title and url are required");
86
+ const link = await runUpsert(parsed.data as z.infer<TInput>);
61
87
  return json(link, { status: 201 });
62
88
  },
63
89
  PUT: async (req) => {
64
- const body = (await safeJson(req)) as LinkBody & ReorderBody;
90
+ const body = (await safeJson(req)) as ReorderBody & Record<string, unknown>;
65
91
  if (Array.isArray(body.orderedIds)) {
66
92
  const ids = body.orderedIds
67
93
  .map((x) => (typeof x === "number" ? x : Number(x)))
@@ -69,18 +95,11 @@ export function createAdminLinksHandlers(
69
95
  await deps.queries.reorderLinks(ids);
70
96
  return json({ ok: true });
71
97
  }
72
- const id = typeof body.id === "number" && Number.isFinite(body.id) ? body.id : null;
73
- if (id == null) return badRequest("id is required");
74
- // upsertLink handles partial updates when given an id; pass through
75
- // every supplied field.
76
- const link = await deps.queries.upsertLink({
77
- id,
78
- title: typeof body.title === "string" ? body.title : "",
79
- url: typeof body.url === "string" ? body.url : "",
80
- position: typeof body.position === "number" ? body.position : undefined,
81
- isVisible: typeof body.isVisible === "boolean" ? body.isVisible : undefined,
82
- showOnHero: typeof body.showOnHero === "boolean" ? body.showOnHero : undefined,
83
- });
98
+ const parsed = inputSchema.safeParse(body);
99
+ if (!parsed.success) return badRequest("Validation failed");
100
+ const dataWithId = parsed.data as { id?: number };
101
+ if (dataWithId.id == null) return badRequest("id is required");
102
+ const link = await runUpsert(parsed.data as z.infer<TInput>);
84
103
  return json(link);
85
104
  },
86
105
  DELETE: async (req) => {
@@ -1,16 +1,19 @@
1
- import type { AdminDeps, RouteHandler } from "../lib/types";
1
+ import type { DefaultTables, Queries, QueryTables } from "@gigamusic/db";
2
+ import type { RouteHandler } from "../lib/types";
2
3
  import { badRequest, json } from "../lib/responses";
3
4
 
4
5
  /**
5
6
  * GET /api/admin/orders?email=… — returns the order history for a given
6
7
  * email address.
7
8
  *
9
+ * Read-only; no `inputSchema` / hooks since there's no write.
10
+ *
8
11
  * Auth: the package assumes the request has been gated by the consumer's
9
12
  * `proxy.ts` / middleware. This handler doesn't verify a session.
10
13
  */
11
- export function createAdminOrdersHandler(
12
- deps: Pick<AdminDeps, "queries">,
13
- ): RouteHandler {
14
+ export function createAdminOrdersHandler<T extends QueryTables = DefaultTables>(deps: {
15
+ queries: Queries<T>;
16
+ }): RouteHandler {
14
17
  return async (req) => {
15
18
  const url = new URL(req.url);
16
19
  const email = url.searchParams.get("email");
@@ -4,15 +4,31 @@ import {
4
4
  TrackInputSchema,
5
5
  type ReleaseInput,
6
6
  } from "@gigamusic/core";
7
- import type { AdminDeps, RouteHandler } from "../lib/types";
7
+ import {
8
+ insertReleaseTx,
9
+ isUniqueConstraintError as isUniqueConstraintErrorByCode,
10
+ updateReleaseTx,
11
+ type DefaultTables,
12
+ type GigamusicDb,
13
+ type Queries,
14
+ type QueryTables,
15
+ type Tx,
16
+ } from "@gigamusic/db";
17
+ import type {
18
+ AdminDeps,
19
+ AdminLogger,
20
+ RouteHandler,
21
+ WriteHookContext,
22
+ } from "../lib/types";
23
+ import type { StorageProvider } from "@gigamusic/storage";
8
24
  import { badRequest, json, notFound } from "../lib/responses";
9
25
 
10
- type ReleasesDeps = Pick<AdminDeps, "queries" | "storage" | "logger">;
11
-
12
26
  /**
13
27
  * Draft releases skip the strict published-validation rules so an admin can
14
- * save in-progress work. We coerce missing numerics to 0 and missing track
15
- * arrays to `[]` so the persistence call sees a complete shape.
28
+ * save in-progress work. Built off the *base* `ReleaseInputSchema` consumer
29
+ * extension columns are NOT carried into the draft variant, because draft
30
+ * validation stays gigamusic-managed. Consumers who want their extras
31
+ * validated on draft must pass an extended `inputSchema` themselves.
16
32
  */
17
33
  const DraftReleaseSchema = ReleaseInputSchema.extend({
18
34
  isPublished: z.literal(false),
@@ -22,8 +38,6 @@ const DraftReleaseSchema = ReleaseInputSchema.extend({
22
38
  tracks: z.array(TrackInputSchema.partial({ files: true, price: true })).optional(),
23
39
  });
24
40
 
25
- const ReleasePayloadSchema = z.union([ReleaseInputSchema, DraftReleaseSchema]);
26
-
27
41
  /**
28
42
  * Flatten zod issues into `{ "tracks[0].artist": ["msg", …] }` shape — matches
29
43
  * the field-error format the admin form expects.
@@ -41,32 +55,74 @@ function flattenIssues(error: z.ZodError): Record<string, string[]> {
41
55
  return out;
42
56
  }
43
57
 
58
+ /**
59
+ * Deps shape shared by both releases handler factories.
60
+ *
61
+ * - `inputSchema`: defaults to `ReleaseInputSchema`. Pass an `.extend()`ed
62
+ * schema to validate consumer extras alongside the gigamusic-managed
63
+ * columns on the *published* path. Draft validation always uses gigamusic's
64
+ * own draft schema; extras are passed straight through.
65
+ * - `beforeWrite` / `afterWrite`: invoked inside the same Drizzle transaction
66
+ * as the gigamusic INSERT/UPDATE. Returning rejected → tx rolls back.
67
+ * `db` + `tables` must be supplied on `AdminDeps` for hooks to be honored.
68
+ */
69
+ export interface ReleasesHandlerDeps<
70
+ T extends QueryTables = DefaultTables,
71
+
72
+ TInput extends z.ZodObject<any> = typeof ReleaseInputSchema,
73
+ > {
74
+ queries: Queries<T>;
75
+ db?: GigamusicDb<T>;
76
+ tables?: T;
77
+ storage?: StorageProvider;
78
+ logger?: AdminLogger;
79
+ inputSchema?: TInput;
80
+ beforeWrite?: (
81
+ input: z.infer<TInput>,
82
+ ctx: WriteHookContext,
83
+ ) => Promise<void>;
84
+ afterWrite?: (
85
+ releaseId: number,
86
+ input: z.infer<TInput>,
87
+ ctx: WriteHookContext,
88
+ ) => Promise<void>;
89
+ }
90
+
44
91
  /**
45
92
  * GET /api/admin/releases and POST /api/admin/releases.
46
93
  *
47
94
  * Auth: the package assumes the request has been gated by the consumer's
48
95
  * `proxy.ts` / middleware. These handlers don't verify a session.
49
96
  */
50
- export function createAdminReleasesHandlers(
51
- deps: Pick<ReleasesDeps, "queries" | "logger">,
52
- ): { GET: RouteHandler; POST: RouteHandler } {
97
+ export function createAdminReleasesHandlers<
98
+ T extends QueryTables = DefaultTables,
99
+
100
+ TInput extends z.ZodObject<any> = typeof ReleaseInputSchema,
101
+ >(deps: ReleasesHandlerDeps<T, TInput>): { GET: RouteHandler; POST: RouteHandler } {
102
+ const inputSchema = (deps.inputSchema ?? ReleaseInputSchema) as TInput;
103
+ const payloadSchema = z.union([inputSchema, DraftReleaseSchema]);
104
+ const hooksConfigured = Boolean(deps.beforeWrite || deps.afterWrite);
105
+
53
106
  return {
54
107
  GET: async () => {
55
108
  const releases = await deps.queries.listAllReleases();
56
109
  return json(releases);
57
110
  },
58
111
  POST: async (req) => {
59
- const body = await readJson(req);
112
+ const body = await readJson(req);
60
113
  if (!body.ok) return badRequest("Invalid JSON");
61
114
 
62
- const parsed = ReleasePayloadSchema.safeParse(body.value);
115
+ const parsed = payloadSchema.safeParse(body.value);
63
116
  if (!parsed.success) {
64
117
  return badRequest("Validation failed", {
65
118
  fieldErrors: flattenIssues(parsed.error),
66
119
  });
67
120
  }
121
+
68
122
  try {
69
- const release = await deps.queries.createRelease(parsed.data as ReleaseInput);
123
+ const release = hooksConfigured
124
+ ? await createReleaseWithHooks(deps, parsed.data as z.infer<TInput>)
125
+ : await deps.queries.createRelease(parsed.data as unknown as ReleaseInput);
70
126
  return json(release, { status: 201 });
71
127
  } catch (err) {
72
128
  if (isUniqueConstraintError(err)) {
@@ -86,24 +142,32 @@ const body = await readJson(req);
86
142
  *
87
143
  * Auth: gated by the consumer upstream — see `createAdminReleasesHandlers`.
88
144
  */
89
- export function createAdminReleaseByIdHandlers(
90
- deps: ReleasesDeps,
145
+ export function createAdminReleaseByIdHandlers<
146
+ T extends QueryTables = DefaultTables,
147
+
148
+ TInput extends z.ZodObject<any> = typeof ReleaseInputSchema,
149
+ >(
150
+ deps: ReleasesHandlerDeps<T, TInput> & { storage: StorageProvider },
91
151
  ): { GET: RouteHandler; PUT: RouteHandler; DELETE: RouteHandler } {
152
+ const inputSchema = (deps.inputSchema ?? ReleaseInputSchema) as TInput;
153
+ const payloadSchema = z.union([inputSchema, DraftReleaseSchema]);
154
+ const hooksConfigured = Boolean(deps.beforeWrite || deps.afterWrite);
155
+
92
156
  return {
93
157
  GET: async (_req, ctx) => {
94
- const id = await resolveIdParam(ctx);
158
+ const id = await resolveIdParam(ctx);
95
159
  if (id == null) return badRequest("Missing id");
96
160
  const all = await deps.queries.listAllReleases();
97
- const found = all.find((r) => r.id === id);
161
+ const found = all.find((r) => (r as unknown as { id: number }).id === id);
98
162
  return found ? json(found) : notFound("Release not found");
99
163
  },
100
164
  PUT: async (req, ctx) => {
101
- const id = await resolveIdParam(ctx);
165
+ const id = await resolveIdParam(ctx);
102
166
  if (id == null) return badRequest("Missing id");
103
167
 
104
168
  const body = await readJson(req);
105
169
  if (!body.ok) return badRequest("Invalid JSON");
106
- const parsed = ReleasePayloadSchema.safeParse(body.value);
170
+ const parsed = payloadSchema.safeParse(body.value);
107
171
  if (!parsed.success) {
108
172
  return badRequest("Validation failed", {
109
173
  fieldErrors: flattenIssues(parsed.error),
@@ -111,10 +175,12 @@ const id = await resolveIdParam(ctx);
111
175
  }
112
176
 
113
177
  try {
114
- const updated = await deps.queries.updateRelease(
115
- id,
116
- parsed.data as Partial<ReleaseInput>,
117
- );
178
+ const updated = hooksConfigured
179
+ ? await updateReleaseWithHooks(deps, id, parsed.data as z.infer<TInput>)
180
+ : await deps.queries.updateRelease(
181
+ id,
182
+ parsed.data as unknown as Partial<ReleaseInput>,
183
+ );
118
184
  return json(updated);
119
185
  } catch (err) {
120
186
  if (isUniqueConstraintError(err)) {
@@ -127,16 +193,19 @@ const id = await resolveIdParam(ctx);
127
193
  }
128
194
  },
129
195
  DELETE: async (_req, ctx) => {
130
- const id = await resolveIdParam(ctx);
196
+ const id = await resolveIdParam(ctx);
131
197
  if (id == null) return badRequest("Missing id");
132
198
 
133
199
  // Snapshot storage keys before the cascade nukes the rows, so we can
134
200
  // clean up the bucket without an orphaned-file scan.
135
201
  const all = await deps.queries.listAllReleases();
136
- const release = all.find((r) => r.id === id);
202
+ const release = all.find((r) => (r as unknown as { id: number }).id === id);
137
203
  if (!release) return notFound("Release not found");
138
204
 
139
- const keys = collectStorageKeys(release, deps.storage);
205
+ const keys = collectStorageKeys(
206
+ release as unknown as ReleaseWithFiles,
207
+ deps.storage,
208
+ );
140
209
  await deps.queries.deleteRelease(id);
141
210
  await Promise.all(
142
211
  keys.map((key) =>
@@ -155,6 +224,68 @@ const id = await resolveIdParam(ctx);
155
224
  };
156
225
  }
157
226
 
227
+ async function createReleaseWithHooks<
228
+ T extends QueryTables,
229
+
230
+ TInput extends z.ZodObject<any>,
231
+ >(deps: ReleasesHandlerDeps<T, TInput>, input: z.infer<TInput>): Promise<unknown> {
232
+ if (!deps.db || !deps.tables) {
233
+ throw new Error(
234
+ "createAdminReleasesHandlers: beforeWrite/afterWrite require deps.db and deps.tables.",
235
+ );
236
+ }
237
+ const db = deps.db;
238
+ const tables = deps.tables;
239
+ const releaseId = await db.transaction(async (tx) => {
240
+ const ctx: WriteHookContext = { tx: tx as Tx, logger: deps.logger };
241
+ if (deps.beforeWrite) await deps.beforeWrite(input, ctx);
242
+ const id = await insertReleaseTx(tx as Tx, tables, input as unknown as ReleaseInput);
243
+ if (deps.afterWrite) await deps.afterWrite(id, input, ctx);
244
+ return id;
245
+ });
246
+ // Re-fetch outside the tx so the row includes everything (including extras
247
+ // the consumer wrote via `afterWrite`).
248
+ const slug = (input as { slug?: string }).slug;
249
+ if (slug) {
250
+ const row = await deps.queries.getReleaseBySlug(slug);
251
+ if (row) return row;
252
+ }
253
+ // Fallback — slug missing or row not found by slug; pull via listAll + id.
254
+ const all = await deps.queries.listAllReleases();
255
+ return all.find((r) => (r as unknown as { id: number }).id === releaseId);
256
+ }
257
+
258
+ async function updateReleaseWithHooks<
259
+ T extends QueryTables,
260
+
261
+ TInput extends z.ZodObject<any>,
262
+ >(
263
+ deps: ReleasesHandlerDeps<T, TInput>,
264
+ id: number,
265
+ input: z.infer<TInput>,
266
+ ): Promise<unknown> {
267
+ if (!deps.db || !deps.tables) {
268
+ throw new Error(
269
+ "createAdminReleaseByIdHandlers: beforeWrite/afterWrite require deps.db and deps.tables.",
270
+ );
271
+ }
272
+ const db = deps.db;
273
+ const tables = deps.tables;
274
+ await db.transaction(async (tx) => {
275
+ const ctx: WriteHookContext = { tx: tx as Tx, logger: deps.logger };
276
+ if (deps.beforeWrite) await deps.beforeWrite(input, ctx);
277
+ await updateReleaseTx(
278
+ tx as Tx,
279
+ tables,
280
+ id,
281
+ input as unknown as Partial<ReleaseInput>,
282
+ );
283
+ if (deps.afterWrite) await deps.afterWrite(id, input, ctx);
284
+ });
285
+ const all = await deps.queries.listAllReleases();
286
+ return all.find((r) => (r as unknown as { id: number }).id === id);
287
+ }
288
+
158
289
  interface ReleaseWithFiles {
159
290
  coverImageUrl: string | null;
160
291
  tracks: Array<{ files: Array<{ storageKey: string }> }>;
@@ -182,18 +313,15 @@ function collectStorageKeys(
182
313
  }
183
314
 
184
315
  /**
185
- * Postgres SQLSTATE 23505 unique_violation. Walks the `cause` chain because
186
- * Drizzle's transaction wrapper re-throws with `code: undefined` and nests the
187
- * original pg error under `err.cause`.
316
+ * Catches the canonical pg unique-violation (via `@gigamusic/db`'s
317
+ * cause-walking helper) AND a message-text fallback for `Queries`
318
+ * implementations that surface plain `Error`s with a slug-related prefix
319
+ * rather than a code.
188
320
  */
189
- function isUniqueConstraintError(err: unknown, depth = 0): boolean {
190
- if (!err || typeof err !== "object" || depth > 5) return false;
191
- const code = (err as { code?: unknown }).code;
192
- if (code === "23505") return true;
193
- // Some Queries implementations may surface plain Errors with this prefix.
194
- const message = (err as { message?: unknown }).message;
195
- if (typeof message === "string" && /slug.*(already|exists|unique)/i.test(message)) return true;
196
- return isUniqueConstraintError((err as { cause?: unknown }).cause, depth + 1);
321
+ function isUniqueConstraintError(err: unknown): boolean {
322
+ if (isUniqueConstraintErrorByCode(err)) return true;
323
+ const message = (err as { message?: unknown } | null | undefined)?.message;
324
+ return typeof message === "string" && /slug.*(already|exists|unique)/i.test(message);
197
325
  }
198
326
 
199
327
  async function resolveIdParam(
@@ -214,3 +342,5 @@ async function readJson(req: Request): Promise<{ ok: true; value: unknown } | {
214
342
  }
215
343
  }
216
344
 
345
+ // Quiet unused-import lint when AdminDeps isn't needed externally.
346
+ export type { AdminDeps };
@@ -1,9 +1,29 @@
1
- import type { AdminDeps, RouteHandler } from "../lib/types";
1
+ import { z } from "zod";
2
+ import type { DefaultTables, Queries, QueryTables } from "@gigamusic/db";
3
+ import type { AdminLogger, RouteHandler } from "../lib/types";
2
4
  import { badRequest, json } from "../lib/responses";
3
5
 
4
- interface SettingPayload {
5
- key?: unknown;
6
- value?: unknown;
6
+ /**
7
+ * Default settings payload schema — `{ key: string, value: unknown }`. Settings
8
+ * are heterogeneous so the default `value` is permissive; consumers wanting
9
+ * per-key validation pass a `.extend()`ed/`.refine()`d schema as `inputSchema`.
10
+ */
11
+ export const SettingInputSchema = z.object({
12
+ key: z.string().trim().min(1),
13
+ value: z.unknown(),
14
+ });
15
+
16
+ export interface SettingsHandlerDeps<
17
+ T extends QueryTables = DefaultTables,
18
+
19
+ TInput extends z.ZodObject<any> = typeof SettingInputSchema,
20
+ > {
21
+ queries: Queries<T>;
22
+ logger?: AdminLogger;
23
+ /** Defaults to `SettingInputSchema`. Pass a tighter schema for per-key validation. */
24
+ inputSchema?: TInput;
25
+ /** Invoked after `queries.setSetting` resolves. Not tx-atomic. */
26
+ afterWrite?: (input: z.infer<TInput>) => Promise<void>;
7
27
  }
8
28
 
9
29
  /**
@@ -14,16 +34,18 @@ interface SettingPayload {
14
34
  * of truth for serialization (JSON stringification happens inside the db
15
35
  * layer).
16
36
  *
17
- * Per-key validation is intentionally not done here settings are
18
- * heterogeneous and the admin form for each setting validates before
19
- * submitting.
37
+ * Per-key validation can be wired via `inputSchema`; settings remain
38
+ * heterogeneous so the default schema accepts any `value`.
20
39
  *
21
40
  * Auth: the package assumes the request has been gated by the consumer's
22
41
  * `proxy.ts` / middleware. These handlers don't verify a session.
23
42
  */
24
- export function createAdminSettingsHandlers(
25
- deps: Pick<AdminDeps, "queries">,
26
- ): { GET: RouteHandler; PUT: RouteHandler } {
43
+ export function createAdminSettingsHandlers<
44
+ T extends QueryTables = DefaultTables,
45
+
46
+ TInput extends z.ZodObject<any> = typeof SettingInputSchema,
47
+ >(deps: SettingsHandlerDeps<T, TInput>): { GET: RouteHandler; PUT: RouteHandler } {
48
+ const inputSchema = (deps.inputSchema ?? SettingInputSchema) as TInput;
27
49
  return {
28
50
  GET: async (req) => {
29
51
  const url = new URL(req.url);
@@ -35,16 +57,17 @@ export function createAdminSettingsHandlers(
35
57
  return json({});
36
58
  },
37
59
  PUT: async (req) => {
38
- let body: SettingPayload;
60
+ let body: unknown;
39
61
  try {
40
- body = (await req.json()) as SettingPayload;
62
+ body = await req.json();
41
63
  } catch {
42
64
  return badRequest("Invalid JSON");
43
65
  }
44
- if (typeof body.key !== "string" || body.key.length === 0) {
45
- return badRequest("key is required");
46
- }
47
- await deps.queries.setSetting(body.key, body.value);
66
+ const parsed = inputSchema.safeParse(body);
67
+ if (!parsed.success) return badRequest("Validation failed");
68
+ const data = parsed.data as { key: string; value: unknown };
69
+ await deps.queries.setSetting(data.key, data.value);
70
+ if (deps.afterWrite) await deps.afterWrite(parsed.data as z.infer<TInput>);
48
71
  return json({ ok: true });
49
72
  },
50
73
  };
@@ -3,11 +3,15 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { writeFile, readFile, unlink, stat } from "node:fs/promises";
5
5
  import { createReadStream } from "node:fs";
6
+ import type { DefaultTables, Queries, QueryTables } from "@gigamusic/db";
6
7
  import type { AdminDeps, RouteHandler } from "../lib/types";
7
8
  import { badRequest, json, serverError } from "../lib/responses";
8
9
 
9
10
  type PresignDeps = Pick<AdminDeps, "storage">;
10
- type ProcessDeps = Pick<AdminDeps, "storage" | "audio" | "queries" | "logger">;
11
+ type ProcessDeps<T extends QueryTables = DefaultTables> = Pick<
12
+ AdminDeps<T>,
13
+ "storage" | "audio" | "logger"
14
+ > & { queries: Queries<T> };
11
15
 
12
16
  // Path segments allow the punctuation common in real-world track filenames
13
17
  // alongside alphanumerics, dashes, dots, underscores, spaces, parens, and
@@ -121,7 +125,9 @@ function bufferFromDataUrl(value: unknown): Buffer | null {
121
125
  * because the track row isn't created yet — the release POST handler
122
126
  * persists the files alongside the row).
123
127
  */
124
- export function createAdminUploadProcessHandler(deps: ProcessDeps): RouteHandler {
128
+ export function createAdminUploadProcessHandler<T extends QueryTables = DefaultTables>(
129
+ deps: ProcessDeps<T>,
130
+ ): RouteHandler {
125
131
  return async (req) => {
126
132
  const body = (await safeJson(req)) as ProcessBody;
127
133
  const key = typeof body.key === "string" ? body.key : "";
package/src/lib/types.ts CHANGED
@@ -1,4 +1,10 @@
1
- import type { Queries } from "@gigamusic/db";
1
+ import type {
2
+ DefaultTables,
3
+ GigamusicDb,
4
+ Queries,
5
+ QueryTables,
6
+ Tx,
7
+ } from "@gigamusic/db";
2
8
  import type { StorageProvider } from "@gigamusic/storage";
3
9
  import type * as Audio from "@gigamusic/audio";
4
10
 
@@ -18,12 +24,28 @@ export type RouteHandler = (
18
24
  * bag) into each factory; nothing inside the admin package reads
19
25
  * `process.env` directly.
20
26
  *
27
+ * Generic over the consumer's `QueryTables` shape so the underlying `Queries`
28
+ * and `beforeWrite`/`afterWrite` hooks see consumer-extended tables when the
29
+ * consumer passed `buildX(extras)` tables in.
30
+ *
21
31
  * Auth deliberately isn't here: the package's handlers trust that the
22
32
  * request has been gated by the consumer's `proxy.ts` (or middleware /
23
33
  * route-level wrapper). See `docs/setup.md` for auth recipes.
24
34
  */
25
- export interface AdminDeps {
26
- queries: Queries;
35
+ export interface AdminDeps<T extends QueryTables = DefaultTables> {
36
+ queries: Queries<T>;
37
+ /**
38
+ * Raw Drizzle instance — used by handlers that need to open a transaction
39
+ * for the `beforeWrite` / `afterWrite` hooks. Not required when no hooks
40
+ * are configured.
41
+ */
42
+ db?: GigamusicDb<T>;
43
+ /**
44
+ * The consumer's table identities. Required only when a hook-using handler
45
+ * is constructed so the package can run tx-bound writes against the same
46
+ * tables the consumer's hook will write to.
47
+ */
48
+ tables?: T;
27
49
  storage: StorageProvider;
28
50
  audio: typeof Audio;
29
51
  /**
@@ -44,3 +66,14 @@ export const noopLogger: AdminLogger = {
44
66
  warn() {},
45
67
  error() {},
46
68
  };
69
+
70
+ /**
71
+ * Context passed to `beforeWrite` / `afterWrite` hooks. `tx` is the active
72
+ * Drizzle transaction the gigamusic INSERT/UPDATE runs inside — consumer
73
+ * hooks INSERT/UPDATE their own tables through this handle, not through
74
+ * their app's `db` singleton, so failures roll back the gigamusic write.
75
+ */
76
+ export interface WriteHookContext {
77
+ tx: Tx;
78
+ logger?: AdminLogger;
79
+ }
package/src/server.ts CHANGED
@@ -16,6 +16,7 @@ export {
16
16
  createAdminReleasesHandlers,
17
17
  createAdminReleaseByIdHandlers,
18
18
  } from "./handlers/releases";
19
+ export type { ReleasesHandlerDeps } from "./handlers/releases";
19
20
  export {
20
21
  createAdminUploadPresignHandler,
21
22
  createAdminUploadProcessHandler,
@@ -28,4 +29,5 @@ export type {
28
29
  AdminDeps,
29
30
  AdminLogger,
30
31
  RouteHandler,
32
+ WriteHookContext,
31
33
  } from "./lib/types";