@byline/db-postgres 1.10.3 → 1.11.1

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.
@@ -207,6 +207,25 @@ export declare const documents: import("drizzle-orm/pg-core").PgTableWithColumns
207
207
  identity: undefined;
208
208
  generated: undefined;
209
209
  }, {}, {}>;
210
+ order_key: import("drizzle-orm/pg-core").PgColumn<{
211
+ name: "order_key";
212
+ tableName: "byline_documents";
213
+ dataType: "custom";
214
+ columnType: "PgCustomColumn";
215
+ data: string;
216
+ driverParam: string;
217
+ notNull: false;
218
+ hasDefault: false;
219
+ isPrimaryKey: false;
220
+ isAutoincrement: false;
221
+ hasRuntimeDefault: false;
222
+ enumValues: undefined;
223
+ baseColumn: never;
224
+ identity: undefined;
225
+ generated: undefined;
226
+ }, {}, {
227
+ pgColumnBuilderBrand: "PgCustomColumnBuilderBrand";
228
+ }>;
210
229
  created_at: import("drizzle-orm/pg-core").PgColumn<{
211
230
  name: "created_at";
212
231
  tableName: "byline_documents";
@@ -818,6 +837,23 @@ export declare const currentDocumentsView: import("drizzle-orm/pg-core").PgViewW
818
837
  identity: undefined;
819
838
  generated: undefined;
820
839
  }, {}, {}>;
840
+ order_key: import("drizzle-orm/pg-core").PgColumn<{
841
+ name: "order_key";
842
+ tableName: "byline_current_documents";
843
+ dataType: "custom";
844
+ columnType: "PgCustomColumn";
845
+ data: string;
846
+ driverParam: string;
847
+ notNull: false;
848
+ hasDefault: false;
849
+ isPrimaryKey: false;
850
+ isAutoincrement: false;
851
+ hasRuntimeDefault: false;
852
+ enumValues: undefined;
853
+ baseColumn: never;
854
+ identity: undefined;
855
+ generated: undefined;
856
+ }, {}, {}>;
821
857
  }>;
822
858
  export declare const currentPublishedDocumentsView: import("drizzle-orm/pg-core").PgViewWithSelection<"byline_current_published_documents", false, {
823
859
  id: import("drizzle-orm/pg-core").PgColumn<{
@@ -1007,6 +1043,23 @@ export declare const currentPublishedDocumentsView: import("drizzle-orm/pg-core"
1007
1043
  identity: undefined;
1008
1044
  generated: undefined;
1009
1045
  }, {}, {}>;
1046
+ order_key: import("drizzle-orm/pg-core").PgColumn<{
1047
+ name: "order_key";
1048
+ tableName: "byline_current_published_documents";
1049
+ dataType: "custom";
1050
+ columnType: "PgCustomColumn";
1051
+ data: string;
1052
+ driverParam: string;
1053
+ notNull: false;
1054
+ hasDefault: false;
1055
+ isPrimaryKey: false;
1056
+ isAutoincrement: false;
1057
+ hasRuntimeDefault: false;
1058
+ enumValues: undefined;
1059
+ baseColumn: never;
1060
+ identity: undefined;
1061
+ generated: undefined;
1062
+ }, {}, {}>;
1010
1063
  }>;
1011
1064
  export declare const textStore: import("drizzle-orm/pg-core").PgTableWithColumns<{
1012
1065
  name: "byline_store_text";
@@ -6,7 +6,28 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { eq, relations, sql } from 'drizzle-orm';
9
- import { bigint, boolean, date, decimal, index, integer, jsonb, pgTable, pgView, real, text, time, timestamp, unique, uuid, varchar, } from 'drizzle-orm/pg-core';
9
+ import { bigint, boolean, customType, date, decimal, index, integer, jsonb, pgTable, pgView, real, text, time, timestamp, unique, uuid, varchar, } from 'drizzle-orm/pg-core';
10
+ /**
11
+ * `varchar(...)` with explicit byte-wise (C) collation.
12
+ *
13
+ * Used for `byline_documents.order_key` so the column sorts the same way
14
+ * JavaScript string comparison does. The fractional-index algorithm in
15
+ * `@byline/core` (`generateKeyBetween`, `generateNKeysBetween`) is designed
16
+ * against byte-wise ordering; the database default collation (e.g.
17
+ * `en_US.utf8` on most modern installs) is locale-aware and disagrees with
18
+ * JS on cases like `'Zz' vs 'a0'` — which causes a refetch after a drag-
19
+ * reorder to "snap" the moved row back to its original position.
20
+ *
21
+ * Captured here (rather than only in a hand-written migration) so future
22
+ * regenerations from this schema reproduce the COLLATE clause cleanly.
23
+ * See migration `0003_order_key_byte_collation.sql` and `docs/ORDERABLE.md`.
24
+ */
25
+ const varcharByteSorted = customType({
26
+ dataType(config) {
27
+ const len = config?.length ?? 255;
28
+ return `varchar(${len}) COLLATE "C"`;
29
+ },
30
+ });
10
31
  // Collections table
11
32
  export const collections = pgTable('byline_collections', {
12
33
  id: uuid('id').primaryKey(),
@@ -32,9 +53,22 @@ export const documents = pgTable('byline_documents', {
32
53
  collection_id: uuid('collection_id')
33
54
  .notNull()
34
55
  .references(() => collections.id, { onDelete: 'cascade' }),
56
+ // Fractional-index sort key for collections with `orderable: true` in
57
+ // their admin config. Null on collections that haven't opted in, and on
58
+ // pre-existing rows in newly-`orderable` collections (sort NULLS LAST).
59
+ // Admin metadata — never per-version, never EAV; updated by the reorder
60
+ // server fn without bumping documentVersions.
61
+ //
62
+ // Uses `varcharByteSorted` (COLLATE "C") so DB ordering matches JS string
63
+ // comparison — the fractional-index algorithm requires this. See
64
+ // `varcharByteSorted` above and docs/ORDERABLE.md.
65
+ order_key: varcharByteSorted('order_key', { length: 128 }),
35
66
  created_at: timestamp('created_at').defaultNow(),
36
67
  updated_at: timestamp('updated_at').defaultNow(),
37
- }, (table) => [index('idx_documents_collection').on(table.collection_id)]);
68
+ }, (table) => [
69
+ index('idx_documents_collection').on(table.collection_id),
70
+ index('idx_documents_collection_order').on(table.collection_id, table.order_key),
71
+ ]);
38
72
  // Document versions table
39
73
  export const documentVersions = pgTable('byline_document_versions', {
40
74
  id: uuid('id').primaryKey(), // UUIDv7 versioning by default
@@ -143,6 +177,11 @@ export const currentDocumentsView = pgView('byline_current_documents').as((qb) =
143
177
  })
144
178
  .from(documentVersions)
145
179
  .where(eq(documentVersions.is_deleted, false)));
180
+ // `order_key` is sourced from `byline_documents` (the logical-document
181
+ // row, not the version row). Joining it through the view keeps
182
+ // `d.order_key` addressable in findDocuments' ORDER BY without an
183
+ // ad-hoc join per query. Always nullable; null sorts last for
184
+ // collections that haven't opted in to `orderable: true`.
146
185
  return qb
147
186
  .with(sq)
148
187
  .select({
@@ -157,8 +196,10 @@ export const currentDocumentsView = pgView('byline_current_documents').as((qb) =
157
196
  updated_at: sq.updated_at,
158
197
  created_by: sq.created_by,
159
198
  change_summary: sq.change_summary,
199
+ order_key: documents.order_key,
160
200
  })
161
201
  .from(sq)
202
+ .innerJoin(documents, eq(documents.id, sq.document_id))
162
203
  .where(eq(sq.rn, 1));
163
204
  });
164
205
  // Current Published Documents View - gets the latest version of each logical
@@ -198,8 +239,10 @@ export const currentPublishedDocumentsView = pgView('byline_current_published_do
198
239
  updated_at: sq.updated_at,
199
240
  created_by: sq.created_by,
200
241
  change_summary: sq.change_summary,
242
+ order_key: documents.order_key,
201
243
  })
202
244
  .from(sq)
245
+ .innerJoin(documents, eq(documents.id, sq.document_id))
203
246
  .where(eq(sq.rn, 1));
204
247
  });
205
248
  // Base field values structure
@@ -22,10 +22,10 @@ export declare class CollectionCommands implements ICollectionCommands {
22
22
  id: string;
23
23
  created_at: Date | null;
24
24
  updated_at: Date | null;
25
+ config: unknown;
25
26
  path: string;
26
27
  singular: string;
27
28
  plural: string;
28
- config: unknown;
29
29
  version: number;
30
30
  schema_hash: string | null;
31
31
  }[]>;
@@ -79,6 +79,7 @@ export declare class DocumentCommands implements IDocumentCommands {
79
79
  status?: string;
80
80
  createdBy?: string;
81
81
  previousVersionId?: string;
82
+ orderKey?: string;
82
83
  }): Promise<{
83
84
  document: {
84
85
  id: string;
@@ -133,6 +134,15 @@ export declare class DocumentCommands implements IDocumentCommands {
133
134
  softDeleteDocument(params: {
134
135
  document_id: string;
135
136
  }): Promise<number>;
137
+ /**
138
+ * Write `order_key` on a single `byline_documents` row. Single-column
139
+ * metadata update — no new version row, no `documentVersions` touch.
140
+ * `updated_at` on the document row is bumped so list caches invalidate.
141
+ */
142
+ setOrderKey(params: {
143
+ document_id: string;
144
+ order_key: string;
145
+ }): Promise<void>;
136
146
  }
137
147
  export declare function createCommandBuilders(db: DatabaseConnection, defaultContentLocale: string): {
138
148
  collections: CollectionCommands;
@@ -76,6 +76,7 @@ export class DocumentCommands {
76
76
  .values({
77
77
  id: documentId,
78
78
  collection_id: params.collectionId,
79
+ order_key: params.orderKey ?? null,
79
80
  })
80
81
  .returning()
81
82
  .then(getFirstOrThrow('Failed to create document'));
@@ -287,6 +288,20 @@ export class DocumentCommands {
287
288
  .where(eq(documentVersions.document_id, params.document_id));
288
289
  return result.rowCount ?? 0;
289
290
  }
291
+ /**
292
+ * Write `order_key` on a single `byline_documents` row. Single-column
293
+ * metadata update — no new version row, no `documentVersions` touch.
294
+ * `updated_at` on the document row is bumped so list caches invalidate.
295
+ */
296
+ async setOrderKey(params) {
297
+ await this.db
298
+ .update(documents)
299
+ .set({
300
+ order_key: params.order_key,
301
+ updated_at: new Date(),
302
+ })
303
+ .where(eq(documents.id, params.document_id));
304
+ }
290
305
  }
291
306
  export function createCommandBuilders(db, defaultContentLocale) {
292
307
  return {
@@ -30,10 +30,10 @@ export declare class CollectionQueries implements ICollectionQueries {
30
30
  id: string;
31
31
  created_at: Date | null;
32
32
  updated_at: Date | null;
33
+ config: unknown;
33
34
  path: string;
34
35
  singular: string;
35
36
  plural: string;
36
- config: unknown;
37
37
  version: number;
38
38
  schema_hash: string | null;
39
39
  } | undefined>;
@@ -41,10 +41,10 @@ export declare class CollectionQueries implements ICollectionQueries {
41
41
  id: string;
42
42
  created_at: Date | null;
43
43
  updated_at: Date | null;
44
+ config: unknown;
44
45
  path: string;
45
46
  singular: string;
46
47
  plural: string;
47
- config: unknown;
48
48
  version: number;
49
49
  schema_hash: string | null;
50
50
  } | undefined>;
@@ -306,6 +306,48 @@ export declare class DocumentQueries implements IDocumentQueries {
306
306
  document_ids: string[];
307
307
  status?: string;
308
308
  }): Promise<Set<string>>;
309
+ /**
310
+ * getLastOrderKey
311
+ *
312
+ * Largest `order_key` currently in use for the given collection. Used
313
+ * at create-time on `orderable: true` collections to append the new
314
+ * row at the end. Returns `null` when no keyed rows exist yet.
315
+ */
316
+ getLastOrderKey({ collection_id }: {
317
+ collection_id: string;
318
+ }): Promise<string | null>;
319
+ /**
320
+ * getNeighborOrderKeys
321
+ *
322
+ * Resolve the `order_key` values bracketing a target gap in one query.
323
+ * `before_document_id` is the doc the moved row should land *after*;
324
+ * `after_document_id` is the doc it should land *before*. Either or
325
+ * both may be null (append / prepend / empty collection).
326
+ *
327
+ * Resolves both keys in a single round trip to keep the read consistent
328
+ * with the next-key computation that follows in the caller.
329
+ */
330
+ getNeighborOrderKeys({ collection_id, before_document_id, after_document_id, }: {
331
+ collection_id: string;
332
+ before_document_id: string | null;
333
+ after_document_id: string | null;
334
+ }): Promise<{
335
+ left: string | null;
336
+ right: string | null;
337
+ }>;
338
+ /**
339
+ * getCanonicalDocumentOrder
340
+ *
341
+ * Returns every document in the collection in its canonical list-view
342
+ * order: `order_key ASC NULLS LAST, created_at DESC`. Used by the reorder
343
+ * server fn for backfill and recovery from key corruption.
344
+ */
345
+ getCanonicalDocumentOrder({ collection_id, }: {
346
+ collection_id: string;
347
+ }): Promise<Array<{
348
+ id: string;
349
+ order_key: string | null;
350
+ }>>;
309
351
  /**
310
352
  * getDocumentCountsByStatus
311
353
  *
@@ -10,8 +10,8 @@
10
10
  // logger. A future refactor could inject the logger at construction time by
11
11
  // either deferring adapter construction or accepting a lazy logger parameter.
12
12
  import { ERR_DATABASE, ERR_NOT_FOUND, getLogger } from '@byline/core';
13
- import { and, eq, inArray, sql } from 'drizzle-orm';
14
- import { collections, currentDocumentsView, currentPublishedDocumentsView, documentPaths, documentVersions, metaStore, } from '../../database/schema/index.js';
13
+ import { and, desc, eq, inArray, isNotNull, sql } from 'drizzle-orm';
14
+ import { collections, currentDocumentsView, currentPublishedDocumentsView, documentPaths, documents, documentVersions, metaStore, } from '../../database/schema/index.js';
15
15
  import { extractFlattenedFieldValue, restoreFieldSetData } from './storage-restore.js';
16
16
  import { allStoreTypes, storeSelectList, storeTableNames, } from './storage-store-manifest.js';
17
17
  import { resolveStoreTypes } from './storage-utils.js';
@@ -601,6 +601,67 @@ export class DocumentQueries {
601
601
  .groupBy(documentVersions.document_id);
602
602
  return new Set(rows.map((r) => r.document_id));
603
603
  }
604
+ /**
605
+ * getLastOrderKey
606
+ *
607
+ * Largest `order_key` currently in use for the given collection. Used
608
+ * at create-time on `orderable: true` collections to append the new
609
+ * row at the end. Returns `null` when no keyed rows exist yet.
610
+ */
611
+ async getLastOrderKey({ collection_id }) {
612
+ const rows = await this.db
613
+ .select({ order_key: documents.order_key })
614
+ .from(documents)
615
+ .where(and(eq(documents.collection_id, collection_id), isNotNull(documents.order_key)))
616
+ .orderBy(desc(documents.order_key))
617
+ .limit(1);
618
+ return rows[0]?.order_key ?? null;
619
+ }
620
+ /**
621
+ * getNeighborOrderKeys
622
+ *
623
+ * Resolve the `order_key` values bracketing a target gap in one query.
624
+ * `before_document_id` is the doc the moved row should land *after*;
625
+ * `after_document_id` is the doc it should land *before*. Either or
626
+ * both may be null (append / prepend / empty collection).
627
+ *
628
+ * Resolves both keys in a single round trip to keep the read consistent
629
+ * with the next-key computation that follows in the caller.
630
+ */
631
+ async getNeighborOrderKeys({ collection_id, before_document_id, after_document_id, }) {
632
+ const ids = [];
633
+ if (before_document_id)
634
+ ids.push(before_document_id);
635
+ if (after_document_id)
636
+ ids.push(after_document_id);
637
+ if (ids.length === 0) {
638
+ return { left: null, right: null };
639
+ }
640
+ const rows = await this.db
641
+ .select({ id: documents.id, order_key: documents.order_key })
642
+ .from(documents)
643
+ .where(and(eq(documents.collection_id, collection_id), inArray(documents.id, ids)));
644
+ const byId = new Map(rows.map((r) => [r.id, r.order_key]));
645
+ return {
646
+ left: before_document_id ? (byId.get(before_document_id) ?? null) : null,
647
+ right: after_document_id ? (byId.get(after_document_id) ?? null) : null,
648
+ };
649
+ }
650
+ /**
651
+ * getCanonicalDocumentOrder
652
+ *
653
+ * Returns every document in the collection in its canonical list-view
654
+ * order: `order_key ASC NULLS LAST, created_at DESC`. Used by the reorder
655
+ * server fn for backfill and recovery from key corruption.
656
+ */
657
+ async getCanonicalDocumentOrder({ collection_id, }) {
658
+ const rows = await this.db
659
+ .select({ id: documents.id, order_key: documents.order_key })
660
+ .from(documents)
661
+ .where(eq(documents.collection_id, collection_id))
662
+ .orderBy(sql `${documents.order_key} ASC NULLS LAST`, desc(documents.created_at));
663
+ return rows;
664
+ }
604
665
  /**
605
666
  * getDocumentCountsByStatus
606
667
  *
@@ -1037,6 +1098,16 @@ export class DocumentQueries {
1037
1098
  * arrives.
1038
1099
  */
1039
1100
  buildDocumentOrderClause(orderBy, direction) {
1101
+ // `order_key` is the fractional-index column for `orderable: true`
1102
+ // collections. Always sort NULLS LAST with a `created_at DESC` tiebreaker
1103
+ // so unkeyed rows (existing rows in a newly-opted-in collection, or rows
1104
+ // from before the column existed) fall to the bottom in a stable order
1105
+ // until the editor drags them into position.
1106
+ if (orderBy === 'order_key') {
1107
+ return direction === 'desc'
1108
+ ? sql `d.order_key DESC NULLS LAST, d.created_at DESC`
1109
+ : sql `d.order_key ASC NULLS LAST, d.created_at DESC`;
1110
+ }
1040
1111
  const columnMap = {
1041
1112
  created_at: 'd.created_at',
1042
1113
  updated_at: 'd.updated_at',
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/db-postgres",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "1.10.3",
5
+ "version": "1.11.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -52,9 +52,9 @@
52
52
  "pg": "^8.20.0",
53
53
  "uuid": "^14.0.0",
54
54
  "zod": "^4.4.3",
55
- "@byline/auth": "1.10.3",
56
- "@byline/core": "1.10.3",
57
- "@byline/admin": "1.10.3"
55
+ "@byline/core": "1.11.1",
56
+ "@byline/auth": "1.11.1",
57
+ "@byline/admin": "1.11.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@biomejs/biome": "2.4.15",