@gigamusic/links 2.1.0 → 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/links",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "Editable Linktree-style link pages for gigamusic artist sites. Ships a Drizzle schema, admin API handler factories, and platform-detection helpers.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,8 +31,9 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@gigamusic/core": "1.0.0",
35
- "@gigamusic/db": "2.1.0"
34
+ "zod": "^3.24.1",
35
+ "@gigamusic/db": "3.0.0",
36
+ "@gigamusic/core": "3.0.0"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "drizzle-orm": ">=0.45",
@@ -1,5 +1,13 @@
1
1
  import { revalidateTag } from "next/cache";
2
- import type { LinkPageQueries } from "../types";
2
+ import { z } from "zod";
3
+ import { LinkPageInputSchema, LinkPageItemInputSchema } from "@gigamusic/core";
4
+ import type {
5
+ DefaultLinkPageTables,
6
+ LinkPageInput,
7
+ LinkPageItemInput,
8
+ LinkPageQueries,
9
+ LinkPageQueryTables,
10
+ } from "../types";
3
11
  import {
4
12
  INVALID_SLUG_MESSAGE,
5
13
  RESERVED_SLUGS,
@@ -24,11 +32,28 @@ export type RouteHandler<Params = Record<string, string>> = (
24
32
  /**
25
33
  * Deps for the admin-side link-page handler factories.
26
34
  *
35
+ * Generic over the consumer's `LinkPageQueryTables` so the `Queries` and the
36
+ * `afterWrite` callbacks see consumer-extended `linkPages` / `linkPageItems`
37
+ * row shapes when the consumer built tables via `buildLinkPages(...)` /
38
+ * `buildLinkPageItems(...)`.
39
+ *
27
40
  * Auth: the package assumes the request has been gated by the consumer's
28
41
  * `proxy.ts` / middleware. These handlers don't verify a session.
29
42
  */
30
- export interface LinkPagesAdminDeps {
31
- queries: LinkPageQueries;
43
+ export interface LinkPagesAdminDeps<
44
+ T extends LinkPageQueryTables = DefaultLinkPageTables,
45
+ TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
46
+ TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
47
+ > {
48
+ queries: LinkPageQueries<T>;
49
+ /** Defaults to `LinkPageInputSchema`. Pass an `.extend()`ed schema for extras. */
50
+ pageInputSchema?: TPageInput;
51
+ /** Defaults to `LinkPageItemInputSchema`. Pass an `.extend()`ed schema for extras. */
52
+ itemInputSchema?: TItemInput;
53
+ /** Invoked after `queries.createLinkPage` / `updateLinkPage` resolves. Not tx-atomic. */
54
+ afterPageWrite?: (pageId: number, input: z.infer<TPageInput>) => Promise<void>;
55
+ /** Invoked after `queries.addLinkPageItem` / `updateLinkPageItem` resolves. Not tx-atomic. */
56
+ afterItemWrite?: (itemId: number, input: z.infer<TItemInput>) => Promise<void>;
32
57
  }
33
58
 
34
59
  function json(body: unknown, init?: ResponseInit): Response {
@@ -41,15 +66,6 @@ function json(body: unknown, init?: ResponseInit): Response {
41
66
  });
42
67
  }
43
68
 
44
- interface LinkPageBody {
45
- title?: string;
46
- slug?: string;
47
- description?: string | null;
48
- releaseId?: number | null;
49
- coverImageUrl?: string | null;
50
- isPublished?: boolean;
51
- }
52
-
53
69
  function validateSlug(slug: string): { ok: true } | { ok: false; res: Response } {
54
70
  if (!SLUG_PATTERN.test(slug)) {
55
71
  return { ok: false, res: json({ error: INVALID_SLUG_MESSAGE }, { status: 400 }) };
@@ -63,21 +79,30 @@ function validateSlug(slug: string): { ok: true } | { ok: false; res: Response }
63
79
  /**
64
80
  * `app/api/admin/link-pages/route.ts` handlers — list + create.
65
81
  */
66
- export function createAdminLinkPagesHandlers(deps: LinkPagesAdminDeps): {
82
+ export function createAdminLinkPagesHandlers<
83
+ T extends LinkPageQueryTables = DefaultLinkPageTables,
84
+ TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
85
+ TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
86
+ >(deps: LinkPagesAdminDeps<T, TPageInput, TItemInput>): {
67
87
  GET: RouteHandler<Record<string, never>>;
68
88
  POST: RouteHandler<Record<string, never>>;
69
89
  } {
90
+ const pageInputSchema = (deps.pageInputSchema ??
91
+ LinkPageInputSchema) as TPageInput;
70
92
  return {
71
- GET: async (_req) => {
72
- const pages = await deps.queries.listAllLinkPages();
93
+ GET: async () => {
94
+ const pages = await deps.queries.listAllLinkPages();
73
95
  return json(pages);
74
96
  },
75
97
 
76
98
  POST: async (req) => {
77
- const body = (await req.json()) as LinkPageBody;
78
- const title = body.title?.trim();
79
- const slug = body.slug?.trim().toLowerCase();
99
+ const body = await safeJson(req);
100
+ const parsed = pageInputSchema.safeParse(body);
101
+ if (!parsed.success) return json({ error: "Validation failed" }, { status: 400 });
80
102
 
103
+ const data = parsed.data as LinkPageInput;
104
+ const title = data.title?.trim();
105
+ const slug = data.slug?.trim().toLowerCase();
81
106
  if (!title) return json({ error: "Title is required" }, { status: 400 });
82
107
  if (!slug) return json({ error: INVALID_SLUG_MESSAGE }, { status: 400 });
83
108
  const slugCheck = validateSlug(slug);
@@ -87,11 +112,17 @@ const body = (await req.json()) as LinkPageBody;
87
112
  const page = await deps.queries.createLinkPage({
88
113
  title,
89
114
  slug,
90
- description: body.description ?? null,
91
- releaseId: body.releaseId ?? null,
92
- coverImageUrl: body.coverImageUrl ?? null,
93
- isPublished: body.isPublished ?? true,
115
+ description: data.description ?? null,
116
+ releaseId: data.releaseId ?? null,
117
+ coverImageUrl: data.coverImageUrl ?? null,
118
+ isPublished: data.isPublished ?? true,
94
119
  });
120
+ if (deps.afterPageWrite) {
121
+ await deps.afterPageWrite(
122
+ (page as { id: number }).id,
123
+ parsed.data as z.infer<TPageInput>,
124
+ );
125
+ }
95
126
  revalidateTag(LINK_PAGES_TAG, "max");
96
127
  return json(page, { status: 201 });
97
128
  } catch (err) {
@@ -107,14 +138,22 @@ const body = (await req.json()) as LinkPageBody;
107
138
  /**
108
139
  * `app/api/admin/link-pages/[id]/route.ts` handlers — single-page CRUD.
109
140
  */
110
- export function createAdminLinkPageByIdHandlers(deps: LinkPagesAdminDeps): {
141
+ export function createAdminLinkPageByIdHandlers<
142
+ T extends LinkPageQueryTables = DefaultLinkPageTables,
143
+ TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
144
+ TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
145
+ >(deps: LinkPagesAdminDeps<T, TPageInput, TItemInput>): {
111
146
  GET: RouteHandler<{ id: string }>;
112
147
  PUT: RouteHandler<{ id: string }>;
113
148
  DELETE: RouteHandler<{ id: string }>;
114
149
  } {
150
+ const pageInputSchema = (deps.pageInputSchema ??
151
+ LinkPageInputSchema) as TPageInput;
152
+ // Updates accept partials — make every field optional.
153
+ const partialPageSchema = pageInputSchema.partial();
115
154
  return {
116
- GET: async (req, { params }) => {
117
- const { id: idParam } = await params;
155
+ GET: async (_req, { params }) => {
156
+ const { id: idParam } = await params;
118
157
  const id = Number(idParam);
119
158
  if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
120
159
  const page = await deps.queries.getLinkPageById(id);
@@ -123,31 +162,36 @@ const { id: idParam } = await params;
123
162
  },
124
163
 
125
164
  PUT: async (req, { params }) => {
126
- const { id: idParam } = await params;
165
+ const { id: idParam } = await params;
127
166
  const id = Number(idParam);
128
167
  if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
129
- const body = (await req.json()) as LinkPageBody;
168
+ const body = await safeJson(req);
169
+ const parsed = partialPageSchema.safeParse(body);
170
+ if (!parsed.success) return json({ error: "Validation failed" }, { status: 400 });
171
+ const data = parsed.data as Partial<LinkPageInput>;
130
172
 
131
- const update: LinkPageBody = {};
132
- if (body.title !== undefined) {
133
- const title = body.title.trim();
173
+ const update: Partial<LinkPageInput> = {};
174
+ if (data.title !== undefined) {
175
+ const title = data.title.trim();
134
176
  if (!title) return json({ error: "Title is required" }, { status: 400 });
135
177
  update.title = title;
136
178
  }
137
- if (body.slug !== undefined) {
138
- const slug = body.slug.trim().toLowerCase();
179
+ if (data.slug !== undefined) {
180
+ const slug = data.slug.trim().toLowerCase();
139
181
  const slugCheck = validateSlug(slug);
140
182
  if (!slugCheck.ok) return slugCheck.res;
141
183
  update.slug = slug;
142
184
  }
143
- if (body.description !== undefined) update.description = body.description;
144
- if (body.releaseId !== undefined) update.releaseId = body.releaseId;
145
- if (body.coverImageUrl !== undefined)
146
- update.coverImageUrl = body.coverImageUrl;
147
- if (body.isPublished !== undefined) update.isPublished = body.isPublished;
185
+ if (data.description !== undefined) update.description = data.description;
186
+ if (data.releaseId !== undefined) update.releaseId = data.releaseId;
187
+ if (data.coverImageUrl !== undefined) update.coverImageUrl = data.coverImageUrl;
188
+ if (data.isPublished !== undefined) update.isPublished = data.isPublished;
148
189
 
149
190
  try {
150
191
  const page = await deps.queries.updateLinkPage(id, update);
192
+ if (deps.afterPageWrite) {
193
+ await deps.afterPageWrite(id, parsed.data as z.infer<TPageInput>);
194
+ }
151
195
  revalidateTag(LINK_PAGES_TAG, "max");
152
196
  return json(page);
153
197
  } catch (err) {
@@ -158,8 +202,8 @@ const { id: idParam } = await params;
158
202
  }
159
203
  },
160
204
 
161
- DELETE: async (req, { params }) => {
162
- const { id: idParam } = await params;
205
+ DELETE: async (_req, { params }) => {
206
+ const { id: idParam } = await params;
163
207
  const id = Number(idParam);
164
208
  if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
165
209
  await deps.queries.deleteLinkPage(id);
@@ -184,34 +228,52 @@ interface LinkPageItemBody {
184
228
  * PUT updates a single item by `itemId` in the JSON body.
185
229
  * DELETE removes an item by `itemId` in the query string.
186
230
  */
187
- export function createAdminLinkPageItemsHandlers(
188
- deps: LinkPagesAdminDeps,
189
- ): {
231
+ export function createAdminLinkPageItemsHandlers<
232
+ T extends LinkPageQueryTables = DefaultLinkPageTables,
233
+ TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
234
+ TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
235
+ >(deps: LinkPagesAdminDeps<T, TPageInput, TItemInput>): {
190
236
  POST: RouteHandler<{ id: string }>;
191
237
  PUT: RouteHandler<{ id: string }>;
192
238
  DELETE: RouteHandler<{ id: string }>;
193
239
  } {
240
+ const itemInputSchema = (deps.itemInputSchema ??
241
+ LinkPageItemInputSchema) as TItemInput;
242
+ const partialItemSchema = itemInputSchema.partial();
194
243
  return {
195
244
  POST: async (req, { params }) => {
196
- const { id: pageIdParam } = await params;
245
+ const { id: pageIdParam } = await params;
197
246
  const pageId = Number(pageIdParam);
198
247
  if (!Number.isFinite(pageId)) {
199
248
  return json({ error: "Not found" }, { status: 404 });
200
249
  }
201
- const { title, url } = (await req.json()) as { title?: string; url?: string };
202
- if (!title?.trim() || !url?.trim()) {
250
+ const body = await safeJson(req);
251
+ const parsed = itemInputSchema.safeParse(body);
252
+ if (!parsed.success) {
253
+ return json({ error: "Title and URL are required" }, { status: 400 });
254
+ }
255
+ const data = parsed.data as LinkPageItemInput;
256
+ if (!data.title?.trim() || !data.url?.trim()) {
203
257
  return json({ error: "Title and URL are required" }, { status: 400 });
204
258
  }
205
259
  const item = await deps.queries.addLinkPageItem(pageId, {
206
- title: title.trim(),
207
- url: url.trim(),
260
+ title: data.title.trim(),
261
+ url: data.url.trim(),
262
+ position: data.position,
263
+ isVisible: data.isVisible,
208
264
  });
265
+ if (deps.afterItemWrite) {
266
+ await deps.afterItemWrite(
267
+ (item as { id: number }).id,
268
+ parsed.data as z.infer<TItemInput>,
269
+ );
270
+ }
209
271
  revalidateTag(LINK_PAGES_TAG, "max");
210
272
  return json(item, { status: 201 });
211
273
  },
212
274
 
213
275
  PUT: async (req) => {
214
- const body = (await req.json()) as LinkPageItemBody;
276
+ const body = (await safeJson(req)) as LinkPageItemBody;
215
277
  if (body.itemId == null) {
216
278
  return json({ error: "itemId is required" }, { status: 400 });
217
279
  }
@@ -219,18 +281,20 @@ const body = (await req.json()) as LinkPageItemBody;
219
281
  if (!Number.isFinite(itemId)) {
220
282
  return json({ error: "itemId is required" }, { status: 400 });
221
283
  }
222
- const item = await deps.queries.updateLinkPageItem(itemId, {
223
- title: body.title,
224
- url: body.url,
225
- position: body.position,
226
- isVisible: body.isVisible,
227
- });
284
+ // Validate the remaining body against the partial-item schema.
285
+ const { itemId: _itemId, ...rest } = body;
286
+ const parsed = partialItemSchema.safeParse(rest);
287
+ if (!parsed.success) return json({ error: "Validation failed" }, { status: 400 });
288
+ const item = await deps.queries.updateLinkPageItem(itemId, parsed.data);
289
+ if (deps.afterItemWrite) {
290
+ await deps.afterItemWrite(itemId, parsed.data as z.infer<TItemInput>);
291
+ }
228
292
  revalidateTag(LINK_PAGES_TAG, "max");
229
293
  return json(item);
230
294
  },
231
295
 
232
296
  DELETE: async (req) => {
233
- const { searchParams } = new URL(req.url);
297
+ const { searchParams } = new URL(req.url);
234
298
  const itemIdRaw = searchParams.get("itemId");
235
299
  if (!itemIdRaw) {
236
300
  return json({ error: "itemId is required" }, { status: 400 });
@@ -245,3 +309,11 @@ const { searchParams } = new URL(req.url);
245
309
  },
246
310
  };
247
311
  }
312
+
313
+ async function safeJson(req: Request): Promise<unknown> {
314
+ try {
315
+ return await req.json();
316
+ } catch {
317
+ return {};
318
+ }
319
+ }
package/src/index.ts CHANGED
@@ -3,11 +3,13 @@
3
3
 
4
4
  // ── Types ────────────────────────────────────────────────────────────────
5
5
  export type {
6
+ DefaultLinkPageTables,
6
7
  LinkPage,
7
8
  LinkPageItem,
8
9
  LinkPageReleaseRef,
9
10
  LinkPageWithItems,
10
11
  LinkPageQueries,
12
+ LinkPageQueryTables,
11
13
  LinkPageInput,
12
14
  LinkPageItemInput,
13
15
  } from "./types";
@@ -1,14 +1,19 @@
1
1
  import { and, asc, desc, eq, max } from "drizzle-orm";
2
2
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3
3
  import type * as gigamusicDbSchema from "@gigamusic/db/schema";
4
- import type * as linksSchema from "../schema/index.js";
5
- import { linkPageItems, linkPages } from "../schema/index.js";
4
+ import type * as linksSchema from "../schema/index";
5
+ import {
6
+ linkPageItems as baseLinkPageItems,
7
+ linkPages as baseLinkPages,
8
+ } from "../schema/index";
6
9
  import type {
10
+ DefaultLinkPageTables,
7
11
  LinkPage,
8
12
  LinkPageInput,
9
13
  LinkPageItem,
10
14
  LinkPageItemInput,
11
15
  LinkPageQueries,
16
+ LinkPageQueryTables,
12
17
  LinkPageWithItems,
13
18
  } from "../types";
14
19
 
@@ -21,28 +26,49 @@ export type LinkPagesDb = NodePgDatabase<RequiredSchema>;
21
26
  * Factory that binds a Drizzle database into the `LinkPageQueries` contract.
22
27
  * Consumers typically merge the result with `@gigamusic/db`'s `createQueries`.
23
28
  *
29
+ * Pass a `tables` arg to thread consumer-extended `linkPages` / `linkPageItems`
30
+ * (built via the `buildLinkPages` / `buildLinkPageItems` factories) through
31
+ * every query — the returned `LinkPageQueries<T>` rows carry the consumer's
32
+ * extra columns. Single-arg callers fall back to the un-extended convenience
33
+ * tables.
34
+ *
24
35
  * The `db` instance must have been built with a schema that includes the
25
36
  * union of `@gigamusic/db/schema` and `@gigamusic/links/schema`; otherwise
26
37
  * the relational `with: { release: ... }` clause won't resolve at runtime.
27
38
  */
28
- export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
39
+ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries;
40
+ export function createLinkPageQueries<T extends LinkPageQueryTables>(
41
+ db: LinkPagesDb,
42
+ tables: T,
43
+ ): LinkPageQueries<T>;
44
+ export function createLinkPageQueries<T extends LinkPageQueryTables>(
45
+ db: LinkPagesDb,
46
+ tables?: T,
47
+ ): LinkPageQueries<T> {
48
+ // Identity is preserved at runtime; the casts only widen for the type
49
+ // checker so consumer-extended tables don't have to match the base shape
50
+ // exactly.
51
+ const linkPages = (tables?.linkPages ?? baseLinkPages) as unknown as typeof baseLinkPages;
52
+ const linkPageItems = (tables?.linkPageItems ??
53
+ baseLinkPageItems) as unknown as typeof baseLinkPageItems;
54
+
29
55
  return {
30
- listPublishedLinkPages: async (): Promise<LinkPage[]> => {
56
+ listPublishedLinkPages: async (): Promise<LinkPage<T>[]> => {
31
57
  const rows = await db.query.linkPages.findMany({
32
58
  where: eq(linkPages.isPublished, true),
33
59
  orderBy: desc(linkPages.updatedAt),
34
60
  });
35
- return rows;
61
+ return rows as unknown as LinkPage<T>[];
36
62
  },
37
63
 
38
- listAllLinkPages: async (): Promise<LinkPage[]> => {
64
+ listAllLinkPages: async (): Promise<LinkPage<T>[]> => {
39
65
  const rows = await db.query.linkPages.findMany({
40
66
  orderBy: desc(linkPages.updatedAt),
41
67
  });
42
- return rows;
68
+ return rows as unknown as LinkPage<T>[];
43
69
  },
44
70
 
45
- getPublicLinkPageBySlug: async (slug: string): Promise<LinkPageWithItems | null> => {
71
+ getPublicLinkPageBySlug: async (slug: string): Promise<LinkPageWithItems<T> | null> => {
46
72
  const row = await db.query.linkPages.findFirst({
47
73
  where: and(eq(linkPages.slug, slug), eq(linkPages.isPublished, true)),
48
74
  with: {
@@ -61,10 +87,10 @@ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
61
87
  },
62
88
  },
63
89
  });
64
- return (row ?? null) as unknown as LinkPageWithItems | null;
90
+ return (row ?? null) as unknown as LinkPageWithItems<T> | null;
65
91
  },
66
92
 
67
- getLinkPageById: async (id: number): Promise<LinkPageWithItems | null> => {
93
+ getLinkPageById: async (id: number): Promise<LinkPageWithItems<T> | null> => {
68
94
  const row = await db.query.linkPages.findFirst({
69
95
  where: eq(linkPages.id, id),
70
96
  with: {
@@ -80,10 +106,10 @@ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
80
106
  items: { orderBy: asc(linkPageItems.position) },
81
107
  },
82
108
  });
83
- return (row ?? null) as unknown as LinkPageWithItems | null;
109
+ return (row ?? null) as unknown as LinkPageWithItems<T> | null;
84
110
  },
85
111
 
86
- createLinkPage: async (input: LinkPageInput): Promise<LinkPage> => {
112
+ createLinkPage: async (input: LinkPageInput): Promise<LinkPage<T>> => {
87
113
  const [row] = await db
88
114
  .insert(linkPages)
89
115
  .values({
@@ -95,10 +121,10 @@ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
95
121
  isPublished: input.isPublished ?? true,
96
122
  })
97
123
  .returning();
98
- return row!;
124
+ return row! as unknown as LinkPage<T>;
99
125
  },
100
126
 
101
- updateLinkPage: async (id: number, input: Partial<LinkPageInput>): Promise<LinkPage> => {
127
+ updateLinkPage: async (id: number, input: Partial<LinkPageInput>): Promise<LinkPage<T>> => {
102
128
  const data: Record<string, unknown> = {};
103
129
  if (input.title !== undefined) data.title = input.title;
104
130
  if (input.slug !== undefined) data.slug = input.slug;
@@ -107,14 +133,17 @@ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
107
133
  if (input.coverImageUrl !== undefined) data.coverImageUrl = input.coverImageUrl;
108
134
  if (input.isPublished !== undefined) data.isPublished = input.isPublished;
109
135
  const [row] = await db.update(linkPages).set(data).where(eq(linkPages.id, id)).returning();
110
- return row!;
136
+ return row! as unknown as LinkPage<T>;
111
137
  },
112
138
 
113
139
  deleteLinkPage: async (id: number): Promise<void> => {
114
140
  await db.delete(linkPages).where(eq(linkPages.id, id));
115
141
  },
116
142
 
117
- addLinkPageItem: async (pageId: number, input: LinkPageItemInput): Promise<LinkPageItem> => {
143
+ addLinkPageItem: async (
144
+ pageId: number,
145
+ input: LinkPageItemInput,
146
+ ): Promise<LinkPageItem<T>> => {
118
147
  const [maxRow] = await db
119
148
  .select({ max: max(linkPageItems.position) })
120
149
  .from(linkPageItems)
@@ -130,20 +159,24 @@ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
130
159
  isVisible: input.isVisible ?? true,
131
160
  })
132
161
  .returning();
133
- return row!;
162
+ return row! as unknown as LinkPageItem<T>;
134
163
  },
135
164
 
136
165
  updateLinkPageItem: async (
137
166
  id: number,
138
167
  input: Partial<LinkPageItemInput>,
139
- ): Promise<LinkPageItem> => {
168
+ ): Promise<LinkPageItem<T>> => {
140
169
  const data: Record<string, unknown> = {};
141
170
  if (input.title !== undefined) data.title = input.title;
142
171
  if (input.url !== undefined) data.url = input.url;
143
172
  if (input.position !== undefined) data.position = input.position;
144
173
  if (input.isVisible !== undefined) data.isVisible = input.isVisible;
145
- const [row] = await db.update(linkPageItems).set(data).where(eq(linkPageItems.id, id)).returning();
146
- return row!;
174
+ const [row] = await db
175
+ .update(linkPageItems)
176
+ .set(data)
177
+ .where(eq(linkPageItems.id, id))
178
+ .returning();
179
+ return row! as unknown as LinkPageItem<T>;
147
180
  },
148
181
 
149
182
  deleteLinkPageItem: async (id: number): Promise<void> => {
@@ -162,9 +195,17 @@ export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
162
195
  await tx
163
196
  .update(linkPageItems)
164
197
  .set({ position })
165
- .where(and(eq(linkPageItems.id, orderedItemIds[position]!), eq(linkPageItems.pageId, pageId)));
198
+ .where(
199
+ and(
200
+ eq(linkPageItems.id, orderedItemIds[position]!),
201
+ eq(linkPageItems.pageId, pageId),
202
+ ),
203
+ );
166
204
  }
167
205
  });
168
206
  },
169
207
  };
170
208
  }
209
+
210
+ // Re-export DefaultLinkPageTables so the consumer can import it if they want.
211
+ export type { DefaultLinkPageTables };
@@ -1,2 +1,2 @@
1
- export * from "./link-pages.js";
2
- export * from "./relations.js";
1
+ export * from "./link-pages";
2
+ export * from "./relations";
@@ -1,41 +1,56 @@
1
1
  import { boolean, index, integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
2
2
  import { releases } from "@gigamusic/db/schema";
3
+ import type { ExtraColumns } from "@gigamusic/db/schema";
3
4
 
4
- export const linkPageColumns = {
5
- id: serial("id").primaryKey(),
6
- slug: text("slug").notNull().unique("link_pages_slug_key"),
7
- title: text("title").notNull(),
8
- description: text("description"),
9
- coverImageUrl: text("coverImageUrl"),
10
- releaseId: integer("releaseId").references(() => releases.id, { onDelete: "set null" }),
11
- isPublished: boolean("isPublished").notNull().default(true),
12
- createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).notNull().defaultNow(),
13
- updatedAt: timestamp("updatedAt", { mode: "date", precision: 3 })
14
- .notNull()
15
- .$defaultFn(() => new Date())
16
- .$onUpdateFn(() => new Date()),
17
- } as const;
5
+ // See @gigamusic/db/schema/releases.ts for the rationale on inlining
6
+ // columns inside the factory (TTableName generic resolution).
7
+ export function buildLinkPages<TExtras extends ExtraColumns = {}>(
8
+ extras: TExtras = {} as TExtras,
9
+ ) {
10
+ return pgTable(
11
+ "link_pages",
12
+ {
13
+ id: serial("id").primaryKey(),
14
+ slug: text("slug").notNull().unique("link_pages_slug_key"),
15
+ title: text("title").notNull(),
16
+ description: text("description"),
17
+ coverImageUrl: text("coverImageUrl"),
18
+ releaseId: integer("releaseId").references(() => releases.id, { onDelete: "set null" }),
19
+ isPublished: boolean("isPublished").notNull().default(true),
20
+ createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).notNull().defaultNow(),
21
+ updatedAt: timestamp("updatedAt", { mode: "date", precision: 3 })
22
+ .notNull()
23
+ .$defaultFn(() => new Date())
24
+ .$onUpdateFn(() => new Date()),
25
+ ...extras,
26
+ },
27
+ (t) => [index("link_pages_releaseId_idx").on(t.releaseId)],
28
+ );
29
+ }
18
30
 
19
- export const linkPages = pgTable("link_pages", linkPageColumns, (t) => [
20
- index("link_pages_releaseId_idx").on(t.releaseId),
21
- ]);
31
+ export const linkPages = buildLinkPages();
22
32
 
23
- export const linkPageItemColumns = {
24
- id: serial("id").primaryKey(),
25
- pageId: integer("pageId")
26
- .notNull()
27
- .references(() => linkPages.id, { onDelete: "cascade" }),
28
- title: text("title").notNull(),
29
- url: text("url").notNull(),
30
- position: integer("position").notNull().default(0),
31
- isVisible: boolean("isVisible").notNull().default(true),
32
- createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).notNull().defaultNow(),
33
- updatedAt: timestamp("updatedAt", { mode: "date", precision: 3 })
34
- .notNull()
35
- .$defaultFn(() => new Date())
36
- .$onUpdateFn(() => new Date()),
37
- } as const;
33
+ export function buildLinkPageItems<TExtras extends ExtraColumns = {}>(
34
+ extras: TExtras = {} as TExtras,
35
+ ) {
36
+ return pgTable(
37
+ "link_page_items",
38
+ {
39
+ id: serial("id").primaryKey(),
40
+ pageId: integer("pageId").notNull().references(() => linkPages.id, { onDelete: "cascade" }),
41
+ title: text("title").notNull(),
42
+ url: text("url").notNull(),
43
+ position: integer("position").notNull().default(0),
44
+ isVisible: boolean("isVisible").notNull().default(true),
45
+ createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).notNull().defaultNow(),
46
+ updatedAt: timestamp("updatedAt", { mode: "date", precision: 3 })
47
+ .notNull()
48
+ .$defaultFn(() => new Date())
49
+ .$onUpdateFn(() => new Date()),
50
+ ...extras,
51
+ },
52
+ (t) => [index("link_page_items_pageId_idx").on(t.pageId)],
53
+ );
54
+ }
38
55
 
39
- export const linkPageItems = pgTable("link_page_items", linkPageItemColumns, (t) => [
40
- index("link_page_items_pageId_idx").on(t.pageId),
41
- ]);
56
+ export const linkPageItems = buildLinkPageItems();
@@ -1,12 +1,54 @@
1
1
  import { relations } from "drizzle-orm";
2
- import { releases } from "@gigamusic/db/schema";
3
- import { linkPages, linkPageItems } from "./link-pages.js";
2
+ import type { PgTable } from "drizzle-orm/pg-core";
3
+ import { releases as baseReleases } from "@gigamusic/db/schema";
4
+ import {
5
+ linkPages as baseLinkPages,
6
+ linkPageItems as baseLinkPageItems,
7
+ } from "./link-pages";
4
8
 
5
- export const linkPagesRelations = relations(linkPages, ({ one, many }) => ({
6
- release: one(releases, { fields: [linkPages.releaseId], references: [releases.id] }),
7
- items: many(linkPageItems),
8
- }));
9
+ // `PgTable<any>` so consumer concrete tables satisfy these factory params
10
+ // see @gigamusic/db's types.ts for the variance rationale.
11
+ type AnyPgTableValue = PgTable<any>;
9
12
 
10
- export const linkPageItemsRelations = relations(linkPageItems, ({ one }) => ({
11
- page: one(linkPages, { fields: [linkPageItems.pageId], references: [linkPages.id] }),
12
- }));
13
+ export function createLinkPagesRelations(t: {
14
+ linkPages: AnyPgTableValue;
15
+ linkPageItems: AnyPgTableValue;
16
+ releases: AnyPgTableValue;
17
+ }) {
18
+ const linkPages = t.linkPages as unknown as typeof baseLinkPages;
19
+ const linkPageItems = t.linkPageItems as unknown as typeof baseLinkPageItems;
20
+ const releases = t.releases as unknown as typeof baseReleases;
21
+ return relations(linkPages, ({ one, many }) => ({
22
+ release: one(releases, {
23
+ fields: [linkPages.releaseId],
24
+ references: [releases.id],
25
+ }),
26
+ items: many(linkPageItems),
27
+ }));
28
+ }
29
+
30
+ export function createLinkPageItemsRelations(t: {
31
+ linkPages: AnyPgTableValue;
32
+ linkPageItems: AnyPgTableValue;
33
+ }) {
34
+ const linkPages = t.linkPages as unknown as typeof baseLinkPages;
35
+ const linkPageItems = t.linkPageItems as unknown as typeof baseLinkPageItems;
36
+ return relations(linkPageItems, ({ one }) => ({
37
+ page: one(linkPages, {
38
+ fields: [linkPageItems.pageId],
39
+ references: [linkPages.id],
40
+ }),
41
+ }));
42
+ }
43
+
44
+ // Convenience top-level exports bound to the un-extended convenience tables.
45
+ export const linkPagesRelations = createLinkPagesRelations({
46
+ linkPages: baseLinkPages,
47
+ linkPageItems: baseLinkPageItems,
48
+ releases: baseReleases,
49
+ });
50
+
51
+ export const linkPageItemsRelations = createLinkPageItemsRelations({
52
+ linkPages: baseLinkPages,
53
+ linkPageItems: baseLinkPageItems,
54
+ });
package/src/types.ts CHANGED
@@ -1,10 +1,37 @@
1
+ import type { PgTable } from "drizzle-orm/pg-core";
1
2
  import type { LinkPageInput, LinkPageItemInput } from "@gigamusic/core";
2
- import type { linkPageItems, linkPages } from "./schema/index.js";
3
+ import type * as linksSchema from "./schema/index";
3
4
 
4
5
  export type { LinkPageInput, LinkPageItemInput };
5
6
 
7
+ // `PgTable<any>` (not `PgTable<TableConfig>`) so consumer concrete tables
8
+ // satisfy this constraint — Drizzle's column generics are invariant on
9
+ // column shape; see @gigamusic/db's types.ts for the full rationale.
10
+ type AnyPgTableValue = PgTable<any>;
11
+
12
+ /**
13
+ * The bag of Drizzle table identities the link-page queries factory closes
14
+ * over. Consumers can pass extended versions of these tables built via the
15
+ * `buildLinkPages` / `buildLinkPageItems` factories; the `releases` reference
16
+ * is the consumer's `releases` table (for the cover-image fallback join).
17
+ */
18
+ export interface LinkPageQueryTables {
19
+ linkPages: AnyPgTableValue;
20
+ linkPageItems: AnyPgTableValue;
21
+ releases: AnyPgTableValue;
22
+ }
23
+
24
+ export interface DefaultLinkPageTables extends LinkPageQueryTables {
25
+ linkPages: typeof linksSchema.linkPages;
26
+ linkPageItems: typeof linksSchema.linkPageItems;
27
+ // `releases` defaults to the gigamusic convenience table; consumers pass
28
+ // their own extended releases when they have one.
29
+ releases: AnyPgTableValue;
30
+ }
31
+
6
32
  /** A single link button rendered inside a `LinkPage`. */
7
- export type LinkPageItem = typeof linkPageItems.$inferSelect;
33
+ export type LinkPageItem<T extends LinkPageQueryTables = DefaultLinkPageTables> =
34
+ T["linkPageItems"]["$inferSelect"];
8
35
 
9
36
  /** Optional `Release` reference exposed to the public view for cover fallback. */
10
37
  export interface LinkPageReleaseRef {
@@ -16,32 +43,34 @@ export interface LinkPageReleaseRef {
16
43
  }
17
44
 
18
45
  /** Top-level "Linktree-style" page row. */
19
- export type LinkPage = typeof linkPages.$inferSelect;
46
+ export type LinkPage<T extends LinkPageQueryTables = DefaultLinkPageTables> =
47
+ T["linkPages"]["$inferSelect"];
20
48
 
21
49
  /** A `LinkPage` joined with its visible (or all) items and an optional release ref. */
22
- export interface LinkPageWithItems extends LinkPage {
23
- items: LinkPageItem[];
24
- release?: LinkPageReleaseRef | null;
25
- }
50
+ export type LinkPageWithItems<T extends LinkPageQueryTables = DefaultLinkPageTables> =
51
+ LinkPage<T> & {
52
+ items: LinkPageItem<T>[];
53
+ release?: LinkPageReleaseRef | null;
54
+ };
26
55
 
27
56
  /**
28
57
  * Query helpers that close over a Drizzle DB. Consumers merge this with
29
58
  * `@gigamusic/db`'s `Queries` factory result (or pass it through wherever a
30
59
  * `LinkPageQueries` is needed).
31
60
  */
32
- export interface LinkPageQueries {
33
- listPublishedLinkPages(): Promise<LinkPage[]>;
34
- listAllLinkPages(): Promise<LinkPage[]>;
35
- getPublicLinkPageBySlug(slug: string): Promise<LinkPageWithItems | null>;
36
- getLinkPageById(id: number): Promise<LinkPageWithItems | null>;
37
- createLinkPage(input: LinkPageInput): Promise<LinkPage>;
38
- updateLinkPage(id: number, input: Partial<LinkPageInput>): Promise<LinkPage>;
61
+ export interface LinkPageQueries<T extends LinkPageQueryTables = DefaultLinkPageTables> {
62
+ listPublishedLinkPages(): Promise<LinkPage<T>[]>;
63
+ listAllLinkPages(): Promise<LinkPage<T>[]>;
64
+ getPublicLinkPageBySlug(slug: string): Promise<LinkPageWithItems<T> | null>;
65
+ getLinkPageById(id: number): Promise<LinkPageWithItems<T> | null>;
66
+ createLinkPage(input: LinkPageInput): Promise<LinkPage<T>>;
67
+ updateLinkPage(id: number, input: Partial<LinkPageInput>): Promise<LinkPage<T>>;
39
68
  deleteLinkPage(id: number): Promise<void>;
40
- addLinkPageItem(pageId: number, input: LinkPageItemInput): Promise<LinkPageItem>;
69
+ addLinkPageItem(pageId: number, input: LinkPageItemInput): Promise<LinkPageItem<T>>;
41
70
  updateLinkPageItem(
42
71
  id: number,
43
72
  input: Partial<LinkPageItemInput>,
44
- ): Promise<LinkPageItem>;
73
+ ): Promise<LinkPageItem<T>>;
45
74
  deleteLinkPageItem(id: number): Promise<void>;
46
75
  reorderLinkPageItems(pageId: number, orderedItemIds: number[]): Promise<void>;
47
76
  }