@gigamusic/admin 2.1.0 → 4.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.1.0",
3
+ "version": "4.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/audio": "1.0.0",
28
- "@gigamusic/core": "1.0.0",
29
- "@gigamusic/storage": "1.0.0",
30
- "@gigamusic/db": "2.1.0"
27
+ "@gigamusic/core": "3.0.0",
28
+ "@gigamusic/db": "4.0.0",
29
+ "@gigamusic/audio": "3.0.0",
30
+ "@gigamusic/storage": "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,16 +4,31 @@ import {
4
4
  TrackInputSchema,
5
5
  type ReleaseInput,
6
6
  } from "@gigamusic/core";
7
- import { isUniqueConstraintError as isUniqueConstraintErrorByCode } from "@gigamusic/db";
8
- 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";
9
24
  import { badRequest, json, notFound } from "../lib/responses";
10
25
 
11
- type ReleasesDeps = Pick<AdminDeps, "queries" | "storage" | "logger">;
12
-
13
26
  /**
14
27
  * Draft releases skip the strict published-validation rules so an admin can
15
- * save in-progress work. We coerce missing numerics to 0 and missing track
16
- * 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.
17
32
  */
18
33
  const DraftReleaseSchema = ReleaseInputSchema.extend({
19
34
  isPublished: z.literal(false),
@@ -23,8 +38,6 @@ const DraftReleaseSchema = ReleaseInputSchema.extend({
23
38
  tracks: z.array(TrackInputSchema.partial({ files: true, price: true })).optional(),
24
39
  });
25
40
 
26
- const ReleasePayloadSchema = z.union([ReleaseInputSchema, DraftReleaseSchema]);
27
-
28
41
  /**
29
42
  * Flatten zod issues into `{ "tracks[0].artist": ["msg", …] }` shape — matches
30
43
  * the field-error format the admin form expects.
@@ -42,32 +55,74 @@ function flattenIssues(error: z.ZodError): Record<string, string[]> {
42
55
  return out;
43
56
  }
44
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
+
45
91
  /**
46
92
  * GET /api/admin/releases and POST /api/admin/releases.
47
93
  *
48
94
  * Auth: the package assumes the request has been gated by the consumer's
49
95
  * `proxy.ts` / middleware. These handlers don't verify a session.
50
96
  */
51
- export function createAdminReleasesHandlers(
52
- deps: Pick<ReleasesDeps, "queries" | "logger">,
53
- ): { 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
+
54
106
  return {
55
107
  GET: async () => {
56
108
  const releases = await deps.queries.listAllReleases();
57
109
  return json(releases);
58
110
  },
59
111
  POST: async (req) => {
60
- const body = await readJson(req);
112
+ const body = await readJson(req);
61
113
  if (!body.ok) return badRequest("Invalid JSON");
62
114
 
63
- const parsed = ReleasePayloadSchema.safeParse(body.value);
115
+ const parsed = payloadSchema.safeParse(body.value);
64
116
  if (!parsed.success) {
65
117
  return badRequest("Validation failed", {
66
118
  fieldErrors: flattenIssues(parsed.error),
67
119
  });
68
120
  }
121
+
69
122
  try {
70
- 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);
71
126
  return json(release, { status: 201 });
72
127
  } catch (err) {
73
128
  if (isUniqueConstraintError(err)) {
@@ -87,24 +142,32 @@ const body = await readJson(req);
87
142
  *
88
143
  * Auth: gated by the consumer upstream — see `createAdminReleasesHandlers`.
89
144
  */
90
- export function createAdminReleaseByIdHandlers(
91
- 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 },
92
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
+
93
156
  return {
94
157
  GET: async (_req, ctx) => {
95
- const id = await resolveIdParam(ctx);
158
+ const id = await resolveIdParam(ctx);
96
159
  if (id == null) return badRequest("Missing id");
97
160
  const all = await deps.queries.listAllReleases();
98
- const found = all.find((r) => r.id === id);
161
+ const found = all.find((r) => (r as unknown as { id: number }).id === id);
99
162
  return found ? json(found) : notFound("Release not found");
100
163
  },
101
164
  PUT: async (req, ctx) => {
102
- const id = await resolveIdParam(ctx);
165
+ const id = await resolveIdParam(ctx);
103
166
  if (id == null) return badRequest("Missing id");
104
167
 
105
168
  const body = await readJson(req);
106
169
  if (!body.ok) return badRequest("Invalid JSON");
107
- const parsed = ReleasePayloadSchema.safeParse(body.value);
170
+ const parsed = payloadSchema.safeParse(body.value);
108
171
  if (!parsed.success) {
109
172
  return badRequest("Validation failed", {
110
173
  fieldErrors: flattenIssues(parsed.error),
@@ -112,10 +175,12 @@ const id = await resolveIdParam(ctx);
112
175
  }
113
176
 
114
177
  try {
115
- const updated = await deps.queries.updateRelease(
116
- id,
117
- parsed.data as Partial<ReleaseInput>,
118
- );
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
+ );
119
184
  return json(updated);
120
185
  } catch (err) {
121
186
  if (isUniqueConstraintError(err)) {
@@ -128,16 +193,19 @@ const id = await resolveIdParam(ctx);
128
193
  }
129
194
  },
130
195
  DELETE: async (_req, ctx) => {
131
- const id = await resolveIdParam(ctx);
196
+ const id = await resolveIdParam(ctx);
132
197
  if (id == null) return badRequest("Missing id");
133
198
 
134
199
  // Snapshot storage keys before the cascade nukes the rows, so we can
135
200
  // clean up the bucket without an orphaned-file scan.
136
201
  const all = await deps.queries.listAllReleases();
137
- const release = all.find((r) => r.id === id);
202
+ const release = all.find((r) => (r as unknown as { id: number }).id === id);
138
203
  if (!release) return notFound("Release not found");
139
204
 
140
- const keys = collectStorageKeys(release, deps.storage);
205
+ const keys = collectStorageKeys(
206
+ release as unknown as ReleaseWithFiles,
207
+ deps.storage,
208
+ );
141
209
  await deps.queries.deleteRelease(id);
142
210
  await Promise.all(
143
211
  keys.map((key) =>
@@ -156,6 +224,68 @@ const id = await resolveIdParam(ctx);
156
224
  };
157
225
  }
158
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
+
159
289
  interface ReleaseWithFiles {
160
290
  coverImageUrl: string | null;
161
291
  tracks: Array<{ files: Array<{ storageKey: string }> }>;
@@ -212,3 +342,5 @@ async function readJson(req: Request): Promise<{ ok: true; value: unknown } | {
212
342
  }
213
343
  }
214
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";