@byline/core 2.1.3 → 2.2.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.
@@ -164,12 +164,55 @@ export interface IDbAdapter {
164
164
  commands: {
165
165
  collections: ICollectionCommands;
166
166
  documents: IDocumentCommands;
167
+ counters: ICounterCommands;
167
168
  };
168
169
  queries: {
169
170
  collections: ICollectionQueries;
170
171
  documents: IDocumentQueries;
171
172
  };
172
173
  }
174
+ /**
175
+ * Adapter capability for the shared-pool counter mechanism backing the
176
+ * `counter` field type. See `packages/core/src/@types/field-types.ts`
177
+ * (CounterField) for the field-level contract and `docs/COLLECTIONS.md`
178
+ * (Counter fields) for the conceptual overview.
179
+ *
180
+ * Both methods are keyed by the developer-facing `groupName` (the value
181
+ * of `CounterField.group`). The adapter is responsible for translating
182
+ * that into whatever backing primitive it uses (a Postgres SEQUENCE for
183
+ * the Postgres adapter) and for keeping the `byline_counter_groups`
184
+ * registry table in sync.
185
+ */
186
+ export interface ICounterCommands {
187
+ /**
188
+ * Idempotently register a counter group and ensure its backing
189
+ * sequence exists. Called once per discovered group at boot by the
190
+ * collection-bootstrap layer (`@byline/core`). Safe to call
191
+ * concurrently across multiple booting processes — implementations
192
+ * must use `IF NOT EXISTS` / `ON CONFLICT DO NOTHING` semantics so
193
+ * two processes racing on the same group leave the system with
194
+ * exactly one sequence and one registry row.
195
+ *
196
+ * Returns the resolved sequence name so callers (tests, doctor
197
+ * tooling) can inspect what was created without re-deriving it.
198
+ */
199
+ ensureCounterGroup(groupName: string): Promise<{
200
+ groupName: string;
201
+ sequenceName: string;
202
+ }>;
203
+ /**
204
+ * Atomically allocate the next value from the named group's
205
+ * sequence. Called on every document create that includes one or
206
+ * more `counter` fields (see assignCounterValues in
207
+ * document-lifecycle). Throws if the group has not been registered
208
+ * via `ensureCounterGroup` — the lifecycle layer is expected to
209
+ * surface that as a configuration error, not silently retry.
210
+ *
211
+ * Gaps are expected: sequences leak on rolled-back transactions
212
+ * and deletes. The facet-URL use case does not require gapless IDs.
213
+ */
214
+ nextCounterValue(groupName: string): Promise<number>;
215
+ }
173
216
  export interface ICollectionCommands {
174
217
  /**
175
218
  * Insert a new collection row. `opts.version` and `opts.schemaHash` are
@@ -18,6 +18,7 @@ type BaseFieldDataTypes = {
18
18
  float: number;
19
19
  group: never;
20
20
  integer: number;
21
+ counter: number;
21
22
  json: unknown;
22
23
  object: unknown;
23
24
  richText: unknown;
@@ -155,6 +155,24 @@ interface BaseField {
155
155
  * document to be considered valid.
156
156
  */
157
157
  optional?: boolean;
158
+ /**
159
+ * When `true`, the field's input widget renders in a read-only state.
160
+ * The editor can see the value but cannot change it through the form UI.
161
+ *
162
+ * This is a **rendering hint only** — it does not enforce immutability
163
+ * on the server. A determined caller can still send a value via the
164
+ * API. Server-side immutability is a separate concern; for fields
165
+ * whose values must be locked at the storage layer (e.g. allocator-
166
+ * assigned counters), the lifecycle layer enforces that independently
167
+ * of this flag. Use this flag for editor UX (locking computed fields,
168
+ * externally-assigned IDs like DOIs, fields that should only be set
169
+ * by a workflow transition), not as a security boundary.
170
+ *
171
+ * Whether a given widget honours this flag is per-widget — most ui-kit
172
+ * input components support a native readOnly state, and widgets are
173
+ * being progressively updated to forward this prop through.
174
+ */
175
+ readOnly?: boolean;
158
176
  /**
159
177
  * Optional field-level hooks that run on the client during editing.
160
178
  * @see FieldHooks
@@ -336,6 +354,37 @@ export interface DecimalField extends NonlocalizableField {
336
354
  type: 'decimal';
337
355
  defaultValue?: DefaultValue<string>;
338
356
  }
357
+ /**
358
+ * An allocator-assigned integer field whose value is drawn from a shared,
359
+ * monotonically-increasing pool identified by `group`. Multiple counter
360
+ * fields — across the same collection or different collections — that
361
+ * declare the same `group` share a single ID sequence. The first document
362
+ * created with a counter field gets `1`, the next `2`, and so on, regardless
363
+ * of which collection the create originated in.
364
+ *
365
+ * The use case is faceted filtering across small "term" collections (e.g.
366
+ * Topic, Format, Geography) with URLs like `/library?t=1&t=4&t=9`, where
367
+ * each numeric ID resolves to a term in one of the collections via the
368
+ * shared pool.
369
+ *
370
+ * Values are assigned at create time and immutable thereafter — the field
371
+ * has no `defaultValue`, no `validation` slot, and updates that touch the
372
+ * counter field are ignored by the lifecycle layer. Duplicates of a
373
+ * document receive a fresh counter value, not the source's.
374
+ *
375
+ * Backed by a Postgres `SEQUENCE` per group; gaps may occur on rolled-back
376
+ * transactions and deletes, which is fine for the facet-URL use case.
377
+ */
378
+ export interface CounterField extends NonlocalizableField {
379
+ type: 'counter';
380
+ /**
381
+ * Counter group name. Shared across all counter fields — in any
382
+ * collection — that declare the same string. Pick something descriptive
383
+ * and stable (e.g. `'library-facets'`); renaming a group later means
384
+ * creating a new sequence and starting over at 1.
385
+ */
386
+ group: string;
387
+ }
339
388
  export interface JsonField extends LocalizableField {
340
389
  type: 'json';
341
390
  defaultValue?: DefaultValue<unknown>;
@@ -384,7 +433,7 @@ export interface ImageField extends NonlocalizableField {
384
433
  upload?: UploadConfig;
385
434
  }
386
435
  export type StructureField = GroupField | ArrayField | BlocksField;
387
- export type ValueField = TextField | TextAreaField | CheckboxField | BooleanField | SelectField | RichTextField | TimeField | DateField | DateTimeField | FloatField | IntegerField | DecimalField | JsonField | ObjectField | RelationField | FileField | ImageField;
436
+ export type ValueField = TextField | TextAreaField | CheckboxField | BooleanField | SelectField | RichTextField | TimeField | DateField | DateTimeField | FloatField | IntegerField | DecimalField | CounterField | JsonField | ObjectField | RelationField | FileField | ImageField;
388
437
  export type Field = StructureField | ValueField;
389
438
  export type FieldSet = readonly Field[];
390
439
  export type ValueFieldType = ValueField['type'];
package/dist/core.js CHANGED
@@ -11,6 +11,7 @@ import { defineBylineCore, defineServerConfig, getBylineCoreUnsafe } from './con
11
11
  import { createBylineLogger, defineLogger } from './lib/logger.js';
12
12
  import { Registry } from './lib/registry.js';
13
13
  import { ensureCollections } from './services/collection-bootstrap.js';
14
+ import { discoverCounterGroups } from './services/discover-counter-groups.js';
14
15
  import { validateRichTextFieldFlags } from './services/richtext-populate.js';
15
16
  /**
16
17
  * Initialize Byline CMS core services via the typed registry.
@@ -52,6 +53,17 @@ export const initBylineCore = async (config, pinoLogger) => {
52
53
  db: composed.db,
53
54
  logger: composed.logger,
54
55
  });
56
+ // Discover every distinct counter `group` across the registered
57
+ // collections and ensure each one has a backing sequence in the
58
+ // database. Runs after ensureCollections (collection-shape errors
59
+ // surface first) and throws on any structural ban (counter inside
60
+ // array/blocks) or adapter failure — both are fatal config issues
61
+ // that should fail boot rather than show up at request time.
62
+ await discoverCounterGroups({
63
+ definitions: composed.collections,
64
+ db: composed.db,
65
+ logger: composed.logger,
66
+ });
55
67
  const getCollectionRecord = (path) => {
56
68
  const record = collectionRecords.get(path);
57
69
  if (!record) {
@@ -112,6 +112,11 @@ export const fieldToZodSchema = (field, strict = true) => {
112
112
  case 'integer':
113
113
  schema = z.number().int();
114
114
  break;
115
+ case 'counter':
116
+ // Counter values are allocator-assigned; the wrapping below forces
117
+ // the schema to allow undefined regardless of `field.optional`.
118
+ schema = z.number().int();
119
+ break;
115
120
  case 'float':
116
121
  case 'decimal':
117
122
  schema = z.number();
@@ -172,9 +177,13 @@ export const fieldToZodSchema = (field, strict = true) => {
172
177
  default:
173
178
  schema = z.string();
174
179
  }
180
+ // Counter fields are implicitly populated by the lifecycle layer (see
181
+ // assignCounterValues in document-lifecycle), so callers must be able to
182
+ // omit them on create. Treat them as always-optional in input schemas.
183
+ const implicitlySet = field.type === 'counter';
175
184
  // In strict mode respect field.optional; in lenient mode always allow null/undefined so reads
176
185
  // never fail on schema-evolved documents.
177
- return strict && !field.optional ? schema : schema.nullable().optional();
186
+ return strict && !field.optional && !implicitlySet ? schema : schema.nullable().optional();
178
187
  };
179
188
  // Create the base schema that all collections share.
180
189
  // When a collection defines a workflow, status is constrained to its status names;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { type FieldSet } from '../@types/index.js';
9
+ import type { ICounterCommands } from '../@types/db-types.js';
10
+ export interface AssignCounterValuesInput {
11
+ fields: FieldSet;
12
+ /**
13
+ * The document data being written. Mutated in place — counter sites
14
+ * are overwritten with allocator-assigned values (on create / when
15
+ * no previous value exists) or with the previous version's value
16
+ * (on update). Caller-supplied counter values are NOT trusted, even
17
+ * on create: they are always replaced by `nextCounterValue` or by
18
+ * the previous version's value.
19
+ */
20
+ data: Record<string, any>;
21
+ /**
22
+ * Reconstructed fields from the previous version (update path only).
23
+ *
24
+ * When provided, counter values are copied forward from here rather
25
+ * than re-allocated — counter fields are immutable across versions
26
+ * of the same document. If the previous version is missing a value
27
+ * for a counter (e.g. the field was added to the collection after
28
+ * the document was created), a new value is allocated lazily so
29
+ * subsequent updates always see a populated counter.
30
+ *
31
+ * When omitted (create / duplicate / restore-as-new), every counter
32
+ * site is freshly allocated.
33
+ */
34
+ previousData?: Record<string, any>;
35
+ counters: ICounterCommands;
36
+ }
37
+ /**
38
+ * Populate every counter field in `data` with its canonical value
39
+ * before the document is flattened and persisted. Called by the
40
+ * lifecycle layer immediately before `db.commands.documents
41
+ * .createDocumentVersion` so the values land in `store_numeric` on
42
+ * the same write.
43
+ *
44
+ * Behaviour by lifecycle path:
45
+ *
46
+ * - create: `previousData` is undefined → every counter field is
47
+ * freshly allocated, any caller-supplied value is
48
+ * overwritten.
49
+ *
50
+ * - update: `previousData` is the prior version's reconstructed
51
+ * fields → counter values are copied forward. Lazy
52
+ * backfill fires for any counter the prior version is
53
+ * missing (e.g. field added post-hoc).
54
+ *
55
+ * - duplicate: caller strips counter values from the cloned source
56
+ * before invoking the create path → fresh allocation
57
+ * applies. (The strip itself is enforced by passing
58
+ * `previousData: undefined`; even if the clone retains
59
+ * the source's value, the create path overwrites it.)
60
+ */
61
+ export declare function assignCounterValues({ fields, data, previousData, counters, }: AssignCounterValuesInput): Promise<void>;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { isArrayField, isBlocksField, isGroupField, } from '../@types/index.js';
9
+ /**
10
+ * Walk a `(fields, data)` pair and yield every counter-field site,
11
+ * descending into groups but never into arrays or blocks (which are
12
+ * banned for counters at discovery time, see discoverCounterGroups).
13
+ *
14
+ * Unlike `walkFieldTree`, this walker yields counter sites *even when
15
+ * the value is null or undefined* — that's the exact case where we
16
+ * need to allocate a fresh value.
17
+ *
18
+ * A missing or non-object container for a group field is treated as
19
+ * empty: the group is created on demand so the assigned value has
20
+ * somewhere to land.
21
+ */
22
+ function* walkCounterSites(fields, data, pathPrefix = '') {
23
+ for (const field of fields) {
24
+ const path = pathPrefix === '' ? field.name : `${pathPrefix}.${field.name}`;
25
+ if (field.type === 'counter') {
26
+ yield { field, parent: data, key: field.name, path };
27
+ continue;
28
+ }
29
+ if (isGroupField(field)) {
30
+ let container = data[field.name];
31
+ if (container == null || typeof container !== 'object' || Array.isArray(container)) {
32
+ // Materialise the group container so the counter site has a
33
+ // parent to mutate. Counter fields nested in groups on
34
+ // freshly-minted documents arrive with no group object at all.
35
+ container = {};
36
+ data[field.name] = container;
37
+ }
38
+ yield* walkCounterSites(field.fields, container, path);
39
+ continue;
40
+ }
41
+ // arrays / blocks are banned at discovery time — they can't contain
42
+ // counters, so we don't descend. All other value field types are
43
+ // not our concern.
44
+ if (isArrayField(field) || isBlocksField(field))
45
+ continue;
46
+ }
47
+ }
48
+ /**
49
+ * Resolve a counter value already present in `previousData` at the
50
+ * same dotted path as the site. Returns `undefined` if the value is
51
+ * missing or not a finite number.
52
+ */
53
+ function readPreviousValue(previousData, path) {
54
+ const segments = path.split('.');
55
+ let cursor = previousData;
56
+ for (const segment of segments) {
57
+ if (cursor == null || typeof cursor !== 'object' || Array.isArray(cursor))
58
+ return undefined;
59
+ cursor = cursor[segment];
60
+ }
61
+ return typeof cursor === 'number' && Number.isFinite(cursor) ? cursor : undefined;
62
+ }
63
+ /**
64
+ * Populate every counter field in `data` with its canonical value
65
+ * before the document is flattened and persisted. Called by the
66
+ * lifecycle layer immediately before `db.commands.documents
67
+ * .createDocumentVersion` so the values land in `store_numeric` on
68
+ * the same write.
69
+ *
70
+ * Behaviour by lifecycle path:
71
+ *
72
+ * - create: `previousData` is undefined → every counter field is
73
+ * freshly allocated, any caller-supplied value is
74
+ * overwritten.
75
+ *
76
+ * - update: `previousData` is the prior version's reconstructed
77
+ * fields → counter values are copied forward. Lazy
78
+ * backfill fires for any counter the prior version is
79
+ * missing (e.g. field added post-hoc).
80
+ *
81
+ * - duplicate: caller strips counter values from the cloned source
82
+ * before invoking the create path → fresh allocation
83
+ * applies. (The strip itself is enforced by passing
84
+ * `previousData: undefined`; even if the clone retains
85
+ * the source's value, the create path overwrites it.)
86
+ */
87
+ export async function assignCounterValues({ fields, data, previousData, counters, }) {
88
+ const sites = Array.from(walkCounterSites(fields, data));
89
+ if (sites.length === 0)
90
+ return;
91
+ // For each site, decide what value to write and resolve any sequence
92
+ // allocations in parallel. nextCounterValue is independent across
93
+ // groups, so there's no ordering constraint.
94
+ await Promise.all(sites.map(async (site) => {
95
+ // Update path: try to carry forward.
96
+ if (previousData !== undefined) {
97
+ const carried = readPreviousValue(previousData, site.path);
98
+ if (carried !== undefined) {
99
+ site.parent[site.key] = carried;
100
+ return;
101
+ }
102
+ // Lazy backfill — previous version had no value, but the
103
+ // field is declared. Fall through to allocation so the
104
+ // document is never left with a missing counter after an
105
+ // update touches it.
106
+ }
107
+ const value = await counters.nextCounterValue(site.field.group);
108
+ site.parent[site.key] = value;
109
+ }));
110
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,163 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it, vi } from 'vitest';
9
+ import { assignCounterValues } from './assign-counter-values.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Fixtures
12
+ // ---------------------------------------------------------------------------
13
+ function makeCounters(start = 1) {
14
+ let next = start;
15
+ const calls = [];
16
+ return {
17
+ counters: {
18
+ ensureCounterGroup: vi.fn(),
19
+ nextCounterValue: vi.fn(async (group) => {
20
+ calls.push(group);
21
+ return next++;
22
+ }),
23
+ },
24
+ calls,
25
+ };
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // Tests
29
+ // ---------------------------------------------------------------------------
30
+ describe('assignCounterValues', () => {
31
+ it('is a no-op when no counter fields are declared', async () => {
32
+ const { counters, calls } = makeCounters();
33
+ const fields = [{ name: 'title', type: 'text' }];
34
+ const data = { title: 'hello' };
35
+ await assignCounterValues({ fields, data, counters });
36
+ expect(calls).toEqual([]);
37
+ expect(data).toEqual({ title: 'hello' });
38
+ });
39
+ it('allocates a value for a single counter at root on create', async () => {
40
+ const { counters, calls } = makeCounters(42);
41
+ const fields = [
42
+ { name: 'label', type: 'text' },
43
+ { name: 'facetId', type: 'counter', group: 'library-facets' },
44
+ ];
45
+ const data = { label: 'Forestry' };
46
+ await assignCounterValues({ fields, data, counters });
47
+ expect(data.facetId).toBe(42);
48
+ expect(calls).toEqual(['library-facets']);
49
+ });
50
+ it('overwrites a caller-supplied counter value on create', async () => {
51
+ const { counters, calls } = makeCounters(7);
52
+ const fields = [{ name: 'facetId', type: 'counter', group: 'library-facets' }];
53
+ const data = { facetId: 999 };
54
+ await assignCounterValues({ fields, data, counters });
55
+ expect(data.facetId).toBe(7);
56
+ expect(calls).toEqual(['library-facets']);
57
+ });
58
+ it('allocates separate values for multiple counters across groups', async () => {
59
+ const { counters, calls } = makeCounters(1);
60
+ const fields = [
61
+ { name: 'facetId', type: 'counter', group: 'library-facets' },
62
+ { name: 'regionId', type: 'counter', group: 'region-codes' },
63
+ ];
64
+ const data = {};
65
+ await assignCounterValues({ fields, data, counters });
66
+ expect(data.facetId).toBeTypeOf('number');
67
+ expect(data.regionId).toBeTypeOf('number');
68
+ expect(data.facetId).not.toBe(data.regionId);
69
+ expect(calls.sort()).toEqual(['library-facets', 'region-codes']);
70
+ });
71
+ it('descends into group fields and materialises missing group containers', async () => {
72
+ const { counters, calls } = makeCounters(5);
73
+ const fields = [
74
+ {
75
+ name: 'meta',
76
+ type: 'group',
77
+ fields: [
78
+ { name: 'label', type: 'text' },
79
+ { name: 'facetId', type: 'counter', group: 'library-facets' },
80
+ ],
81
+ },
82
+ ];
83
+ const data = {};
84
+ await assignCounterValues({ fields, data, counters });
85
+ expect(data.meta).toBeTypeOf('object');
86
+ expect(data.meta.facetId).toBe(5);
87
+ expect(calls).toEqual(['library-facets']);
88
+ });
89
+ it('carries forward the previous version value on update', async () => {
90
+ const { counters, calls } = makeCounters(100);
91
+ const fields = [
92
+ { name: 'label', type: 'text' },
93
+ { name: 'facetId', type: 'counter', group: 'library-facets' },
94
+ ];
95
+ const data = { label: 'updated' };
96
+ const previousData = { label: 'previous', facetId: 7 };
97
+ await assignCounterValues({ fields, data, previousData, counters });
98
+ expect(data.facetId).toBe(7);
99
+ expect(calls).toEqual([]);
100
+ });
101
+ it('overwrites caller-sent value on update with the previous version value', async () => {
102
+ const { counters, calls } = makeCounters();
103
+ const fields = [{ name: 'facetId', type: 'counter', group: 'library-facets' }];
104
+ const data = { facetId: 999 };
105
+ const previousData = { facetId: 7 };
106
+ await assignCounterValues({ fields, data, previousData, counters });
107
+ expect(data.facetId).toBe(7);
108
+ expect(calls).toEqual([]);
109
+ });
110
+ it('lazy-allocates on update when the previous version is missing the counter', async () => {
111
+ const { counters, calls } = makeCounters(50);
112
+ const fields = [
113
+ { name: 'label', type: 'text' },
114
+ { name: 'facetId', type: 'counter', group: 'library-facets' },
115
+ ];
116
+ const data = { label: 'updated' };
117
+ const previousData = { label: 'previous' }; // counter added post-hoc
118
+ await assignCounterValues({ fields, data, previousData, counters });
119
+ expect(data.facetId).toBe(50);
120
+ expect(calls).toEqual(['library-facets']);
121
+ });
122
+ it('carries forward counter inside a group on update', async () => {
123
+ const { counters, calls } = makeCounters(99);
124
+ const fields = [
125
+ {
126
+ name: 'meta',
127
+ type: 'group',
128
+ fields: [{ name: 'facetId', type: 'counter', group: 'library-facets' }],
129
+ },
130
+ ];
131
+ const data = { meta: {} };
132
+ const previousData = { meta: { facetId: 12 } };
133
+ await assignCounterValues({ fields, data, previousData, counters });
134
+ expect(data.meta.facetId).toBe(12);
135
+ expect(calls).toEqual([]);
136
+ });
137
+ it('treats a non-finite previous value as missing and lazy-allocates', async () => {
138
+ const { counters, calls } = makeCounters(3);
139
+ const fields = [{ name: 'facetId', type: 'counter', group: 'library-facets' }];
140
+ const data = {};
141
+ const previousData = { facetId: 'not-a-number' };
142
+ await assignCounterValues({ fields, data, previousData, counters });
143
+ expect(data.facetId).toBe(3);
144
+ expect(calls).toEqual(['library-facets']);
145
+ });
146
+ it('does not descend into arrays or blocks (counters are banned there)', async () => {
147
+ // The walker should silently skip these — the structural ban is
148
+ // enforced separately in discoverCounterGroups at boot. Here we
149
+ // just verify the walker doesn't crash on an array/blocks sibling.
150
+ const { counters, calls } = makeCounters(1);
151
+ const fields = [
152
+ { name: 'facetId', type: 'counter', group: 'library-facets' },
153
+ { name: 'tags', type: 'array', fields: [{ name: 'label', type: 'text' }] },
154
+ ];
155
+ const data = {
156
+ tags: [{ label: 'a' }, { label: 'b' }],
157
+ };
158
+ await assignCounterValues({ fields, data, counters });
159
+ expect(data.facetId).toBe(1);
160
+ expect(data.tags).toEqual([{ label: 'a' }, { label: 'b' }]);
161
+ expect(calls).toEqual(['library-facets']);
162
+ });
163
+ });
@@ -44,6 +44,10 @@ function createMockDb(options) {
44
44
  softDeleteDocument: vi.fn(fail),
45
45
  setOrderKey: vi.fn(fail),
46
46
  },
47
+ counters: {
48
+ ensureCounterGroup: vi.fn(fail),
49
+ nextCounterValue: vi.fn(fail),
50
+ },
47
51
  },
48
52
  queries: {
49
53
  collections: {
@@ -181,6 +185,10 @@ describe('ensureCollections', () => {
181
185
  softDeleteDocument: vi.fn(),
182
186
  setOrderKey: vi.fn(),
183
187
  },
188
+ counters: {
189
+ ensureCounterGroup: vi.fn(),
190
+ nextCounterValue: vi.fn(),
191
+ },
184
192
  },
185
193
  queries: {
186
194
  collections: {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { type CollectionDefinition } from '../@types/index.js';
9
+ import type { IDbAdapter } from '../@types/db-types.js';
10
+ import type { BylineLogger } from '../lib/logger.js';
11
+ export interface DiscoverCounterGroupsInput {
12
+ definitions: CollectionDefinition[];
13
+ db: IDbAdapter;
14
+ logger?: BylineLogger;
15
+ }
16
+ /**
17
+ * Discover every distinct counter `group` declared across the provided
18
+ * collections and ensure each one is registered with the database
19
+ * adapter (creating its backing sequence as needed). Called once at
20
+ * startup from `initBylineCore()`, after `ensureCollections`.
21
+ *
22
+ * Returns a `Map<groupName, sequenceName>` of the registered groups,
23
+ * suitable for caching on the core instance if downstream callers ever
24
+ * need to inspect the resolved sequence names without round-tripping.
25
+ *
26
+ * Throws on:
27
+ * - a counter field nested inside `array` or `blocks` (see
28
+ * {@link findCounterFields} — structural ban)
29
+ * - any `ensureCounterGroup` failure (treated as a fatal config error)
30
+ */
31
+ export declare function discoverCounterGroups({ definitions, db, logger, }: DiscoverCounterGroupsInput): Promise<Map<string, string>>;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { isArrayField, isBlocksField, isGroupField, } from '../@types/index.js';
9
+ /**
10
+ * Walk a collection's field tree and yield every `counter` field site.
11
+ *
12
+ * Banned locations are thrown immediately (not yielded): a `counter`
13
+ * inside an `array` or `blocks` structure would mean a single document
14
+ * carries multiple counter values, which collapses the "one ID per
15
+ * term" assumption that makes URLs like `?t=1&t=4&t=9` meaningful.
16
+ *
17
+ * Counters inside a `group` are allowed — groups don't repeat, so
18
+ * there's still exactly one counter value per document.
19
+ */
20
+ function* findCounterFields(fields, collectionPath, pathPrefix = '', insideRepeating = null) {
21
+ for (const field of fields) {
22
+ const fieldPath = pathPrefix === '' ? field.name : `${pathPrefix}.${field.name}`;
23
+ if (field.type === 'counter') {
24
+ if (insideRepeating !== null) {
25
+ throw new Error(`discoverCounterGroups: counter field '${fieldPath}' in collection ` +
26
+ `'${collectionPath}' is nested inside a ${insideRepeating.kind} field ` +
27
+ `('${insideRepeating.fieldPath}'). Counter fields produce a single ` +
28
+ `allocator-assigned value per document and cannot live inside ` +
29
+ `repeating structure — move the field to the collection root or ` +
30
+ `into a non-repeating group.`);
31
+ }
32
+ yield { collectionPath, fieldPath, field };
33
+ continue;
34
+ }
35
+ if (isGroupField(field)) {
36
+ yield* findCounterFields(field.fields, collectionPath, fieldPath, insideRepeating);
37
+ continue;
38
+ }
39
+ if (isArrayField(field)) {
40
+ yield* findCounterFields(field.fields, collectionPath, fieldPath, {
41
+ kind: 'array',
42
+ fieldPath,
43
+ });
44
+ continue;
45
+ }
46
+ if (isBlocksField(field)) {
47
+ for (const block of field.blocks) {
48
+ yield* findCounterFields(block.fields, collectionPath, `${fieldPath}.${block.blockType}`, {
49
+ kind: 'blocks',
50
+ fieldPath,
51
+ });
52
+ }
53
+ continue;
54
+ }
55
+ // Other value fields produce no counter sites; nothing to recurse into.
56
+ void field;
57
+ }
58
+ }
59
+ /**
60
+ * Discover every distinct counter `group` declared across the provided
61
+ * collections and ensure each one is registered with the database
62
+ * adapter (creating its backing sequence as needed). Called once at
63
+ * startup from `initBylineCore()`, after `ensureCollections`.
64
+ *
65
+ * Returns a `Map<groupName, sequenceName>` of the registered groups,
66
+ * suitable for caching on the core instance if downstream callers ever
67
+ * need to inspect the resolved sequence names without round-tripping.
68
+ *
69
+ * Throws on:
70
+ * - a counter field nested inside `array` or `blocks` (see
71
+ * {@link findCounterFields} — structural ban)
72
+ * - any `ensureCounterGroup` failure (treated as a fatal config error)
73
+ */
74
+ export async function discoverCounterGroups({ definitions, db, logger, }) {
75
+ // Aggregate distinct group names across all collections. We track the
76
+ // first site we saw each group at so the log line can point a human
77
+ // at where the group was declared.
78
+ const groupSites = new Map();
79
+ for (const definition of definitions) {
80
+ for (const site of findCounterFields(definition.fields, definition.path)) {
81
+ if (!groupSites.has(site.field.group)) {
82
+ groupSites.set(site.field.group, site);
83
+ }
84
+ }
85
+ }
86
+ if (groupSites.size === 0) {
87
+ return new Map();
88
+ }
89
+ // Reconcile each group concurrently — sequences are independent so
90
+ // there's no ordering constraint, and ensureCounterGroup is idempotent
91
+ // and safe under concurrent calls (CREATE SEQUENCE IF NOT EXISTS +
92
+ // ON CONFLICT DO NOTHING).
93
+ const results = await Promise.all(Array.from(groupSites.entries()).map(async ([groupName, site]) => {
94
+ const { sequenceName } = await db.commands.counters.ensureCounterGroup(groupName);
95
+ logger?.debug({
96
+ counterGroup: groupName,
97
+ sequenceName,
98
+ firstSeenIn: `${site.collectionPath}.${site.fieldPath}`,
99
+ }, 'counter group registered');
100
+ return [groupName, sequenceName];
101
+ }));
102
+ const registered = new Map(results);
103
+ logger?.info({ counterGroupCount: registered.size }, 'counter groups reconciled');
104
+ return registered;
105
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,183 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it, vi } from 'vitest';
9
+ import { discoverCounterGroups } from './discover-counter-groups.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Fixtures
12
+ // ---------------------------------------------------------------------------
13
+ function makeAdapter(options) {
14
+ const ensureCounterGroup = vi.fn(options?.ensure ??
15
+ (async (groupName) => ({
16
+ groupName,
17
+ sequenceName: `byline_cseq_${groupName.replace(/-/g, '_')}_abcd1234`,
18
+ })));
19
+ const fail = () => {
20
+ throw new Error('unexpected call');
21
+ };
22
+ const db = {
23
+ commands: {
24
+ collections: { create: vi.fn(fail), update: vi.fn(fail), delete: vi.fn(fail) },
25
+ documents: {
26
+ createDocumentVersion: vi.fn(fail),
27
+ setDocumentStatus: vi.fn(fail),
28
+ archivePublishedVersions: vi.fn(fail),
29
+ softDeleteDocument: vi.fn(fail),
30
+ setOrderKey: vi.fn(fail),
31
+ },
32
+ counters: {
33
+ ensureCounterGroup,
34
+ nextCounterValue: vi.fn(fail),
35
+ },
36
+ },
37
+ queries: {
38
+ collections: {
39
+ getAllCollections: vi.fn(fail),
40
+ getCollectionByPath: vi.fn(fail),
41
+ getCollectionById: vi.fn(fail),
42
+ },
43
+ documents: {
44
+ getDocumentById: vi.fn(fail),
45
+ getCurrentVersionMetadata: vi.fn(fail),
46
+ getDocumentByPath: vi.fn(fail),
47
+ getDocumentByVersion: vi.fn(fail),
48
+ getDocumentsByVersionIds: vi.fn(fail),
49
+ getDocumentsByDocumentIds: vi.fn(fail),
50
+ getDocumentHistory: vi.fn(fail),
51
+ getPublishedVersion: vi.fn(fail),
52
+ getPublishedDocumentIds: vi.fn(fail),
53
+ getDocumentCountsByStatus: vi.fn(fail),
54
+ findDocuments: vi.fn(fail),
55
+ getLastOrderKey: vi.fn(fail),
56
+ getNeighborOrderKeys: vi.fn(fail),
57
+ getCanonicalDocumentOrder: vi.fn(fail),
58
+ },
59
+ },
60
+ };
61
+ return { db, ensureCounterGroup };
62
+ }
63
+ function collection(path, fields) {
64
+ return {
65
+ path,
66
+ labels: { singular: path, plural: `${path}s` },
67
+ fields,
68
+ workflow: {
69
+ statuses: [{ name: 'draft' }, { name: 'published' }, { name: 'archived' }],
70
+ },
71
+ };
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Tests
75
+ // ---------------------------------------------------------------------------
76
+ describe('discoverCounterGroups', () => {
77
+ it('is a no-op when no collections declare any counter fields', async () => {
78
+ const { db, ensureCounterGroup } = makeAdapter();
79
+ const collections = [
80
+ collection('news', [{ name: 'title', type: 'text' }]),
81
+ collection('pages', [{ name: 'body', type: 'textArea' }]),
82
+ ];
83
+ const result = await discoverCounterGroups({ definitions: collections, db });
84
+ expect(result.size).toBe(0);
85
+ expect(ensureCounterGroup).not.toHaveBeenCalled();
86
+ });
87
+ it('registers each distinct group exactly once across all collections', async () => {
88
+ const { db, ensureCounterGroup } = makeAdapter();
89
+ const collections = [
90
+ collection('topics', [
91
+ { name: 'label', type: 'text' },
92
+ { name: 'facetId', type: 'counter', group: 'library-facets' },
93
+ ]),
94
+ collection('formats', [
95
+ { name: 'label', type: 'text' },
96
+ { name: 'facetId', type: 'counter', group: 'library-facets' },
97
+ ]),
98
+ collection('geography', [
99
+ { name: 'label', type: 'text' },
100
+ { name: 'facetId', type: 'counter', group: 'library-facets' },
101
+ ]),
102
+ ];
103
+ const result = await discoverCounterGroups({ definitions: collections, db });
104
+ expect(result.size).toBe(1);
105
+ expect(result.get('library-facets')).toMatch(/^byline_cseq_library_facets_/);
106
+ expect(ensureCounterGroup).toHaveBeenCalledTimes(1);
107
+ expect(ensureCounterGroup).toHaveBeenCalledWith('library-facets');
108
+ });
109
+ it('registers multiple distinct groups in a single pass', async () => {
110
+ const { db, ensureCounterGroup } = makeAdapter();
111
+ const collections = [
112
+ collection('topics', [{ name: 'facetId', type: 'counter', group: 'library-facets' }]),
113
+ collection('regions', [{ name: 'regionId', type: 'counter', group: 'region-codes' }]),
114
+ ];
115
+ const result = await discoverCounterGroups({ definitions: collections, db });
116
+ expect(result.size).toBe(2);
117
+ expect(ensureCounterGroup).toHaveBeenCalledTimes(2);
118
+ expect(ensureCounterGroup).toHaveBeenCalledWith('library-facets');
119
+ expect(ensureCounterGroup).toHaveBeenCalledWith('region-codes');
120
+ });
121
+ it('descends into group fields — counter inside a non-repeating group is allowed', async () => {
122
+ const { db, ensureCounterGroup } = makeAdapter();
123
+ const collections = [
124
+ collection('topics', [
125
+ {
126
+ name: 'meta',
127
+ type: 'group',
128
+ fields: [
129
+ { name: 'label', type: 'text' },
130
+ { name: 'facetId', type: 'counter', group: 'library-facets' },
131
+ ],
132
+ },
133
+ ]),
134
+ ];
135
+ const result = await discoverCounterGroups({ definitions: collections, db });
136
+ expect(result.size).toBe(1);
137
+ expect(ensureCounterGroup).toHaveBeenCalledWith('library-facets');
138
+ });
139
+ it('throws when a counter sits inside an array field', async () => {
140
+ const { db, ensureCounterGroup } = makeAdapter();
141
+ const collections = [
142
+ collection('topics', [
143
+ {
144
+ name: 'variants',
145
+ type: 'array',
146
+ fields: [{ name: 'facetId', type: 'counter', group: 'library-facets' }],
147
+ },
148
+ ]),
149
+ ];
150
+ await expect(discoverCounterGroups({ definitions: collections, db })).rejects.toThrow(/nested inside a array field/);
151
+ expect(ensureCounterGroup).not.toHaveBeenCalled();
152
+ });
153
+ it('throws when a counter sits inside a blocks field', async () => {
154
+ const { db, ensureCounterGroup } = makeAdapter();
155
+ const collections = [
156
+ collection('topics', [
157
+ {
158
+ name: 'content',
159
+ type: 'blocks',
160
+ blocks: [
161
+ {
162
+ blockType: 'tagBlock',
163
+ fields: [{ name: 'facetId', type: 'counter', group: 'library-facets' }],
164
+ },
165
+ ],
166
+ },
167
+ ]),
168
+ ];
169
+ await expect(discoverCounterGroups({ definitions: collections, db })).rejects.toThrow(/nested inside a blocks field/);
170
+ expect(ensureCounterGroup).not.toHaveBeenCalled();
171
+ });
172
+ it('surfaces the adapter error when ensureCounterGroup fails', async () => {
173
+ const { db } = makeAdapter({
174
+ ensure: async () => {
175
+ throw new Error('sequence ddl rejected');
176
+ },
177
+ });
178
+ const collections = [
179
+ collection('topics', [{ name: 'facetId', type: 'counter', group: 'library-facets' }]),
180
+ ];
181
+ await expect(discoverCounterGroups({ definitions: collections, db })).rejects.toThrow('sequence ddl rejected');
182
+ });
183
+ });
@@ -16,6 +16,7 @@ import { normaliseDateFields } from '../utils/normalise-dates.js';
16
16
  import { slugify } from '../utils/slugify.js';
17
17
  import { getUploadFields } from '../utils/storage-utils.js';
18
18
  import { getDefaultStatus, getWorkflow, validateStatusTransition } from '../workflow/workflow.js';
19
+ import { assignCounterValues } from './assign-counter-values.js';
19
20
  // ---------------------------------------------------------------------------
20
21
  // Internal helpers
21
22
  // ---------------------------------------------------------------------------
@@ -173,6 +174,16 @@ export async function createDocument(ctx, params) {
173
174
  }
174
175
  normaliseDateFields(data);
175
176
  await invokeHook(hooks?.beforeCreate, { data, collectionPath });
177
+ // Allocate counter-field values after beforeCreate so user-land hooks
178
+ // can run their own logic on the raw payload, but before the flatten/
179
+ // insert pass so the assigned values are persisted on the same write.
180
+ // Caller-supplied counter values are overwritten — counters are
181
+ // allocator-assigned, never user-set.
182
+ await assignCounterValues({
183
+ fields: definition.fields,
184
+ data,
185
+ counters: db.commands.counters,
186
+ });
176
187
  const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
177
188
  const resolvedPath = explicitPath ?? derivePath(definition, data, defaultLocale, slugifier);
178
189
  // Append-at-end order_key for `orderable: true` collections.
@@ -234,6 +245,18 @@ export async function updateDocument(ctx, params) {
234
245
  const originalData = latest ?? {};
235
246
  normaliseDateFields(data);
236
247
  await invokeHook(hooks?.beforeUpdate, { data, originalData, collectionPath });
248
+ // Counter fields are immutable: carry their values forward from the
249
+ // previous version rather than trusting whatever (or nothing) the
250
+ // caller sent. Lazy-allocates when a counter was added to the
251
+ // collection after this document was first created.
252
+ // originalData is the document envelope (with `.fields`, `.path`,
253
+ // `.document_version_id`); assignCounterValues expects field-shape.
254
+ await assignCounterValues({
255
+ fields: definition.fields,
256
+ data,
257
+ previousData: originalData.fields ?? originalData,
258
+ counters: db.commands.counters,
259
+ });
237
260
  const defaultStatus = getDefaultStatus(definition);
238
261
  const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
239
262
  const requestLocale = params.locale ?? defaultLocale;
@@ -329,6 +352,16 @@ export async function updateDocumentWithPatches(ctx, params) {
329
352
  normaliseDateFields(nextData);
330
353
  // 5. beforeUpdate hook.
331
354
  await invokeHook(hooks?.beforeUpdate, { data: nextData, originalData, collectionPath });
355
+ // 5b. Carry counter values forward from the previous version (or
356
+ // lazy-allocate if the previous version is missing a value). See
357
+ // updateDocument for the rationale — patch-based updates are
358
+ // subject to the same immutability contract.
359
+ await assignCounterValues({
360
+ fields: definition.fields,
361
+ data: nextData,
362
+ previousData: originalData.fields ?? {},
363
+ counters: db.commands.counters,
364
+ });
332
365
  // 6. Persist.
333
366
  const defaultStatus = getDefaultStatus(definition);
334
367
  const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
@@ -878,6 +911,15 @@ export async function duplicateDocument(ctx, params) {
878
911
  collectionPath,
879
912
  duplicate: duplicateMarker,
880
913
  });
914
+ // 6b. Reset counter fields to freshly-allocated values. The clone
915
+ // currently carries the source document's counter values; without
916
+ // this pass, the duplicate would alias the source's facet IDs and
917
+ // break the "one ID per term" contract.
918
+ await assignCounterValues({
919
+ fields: definition.fields,
920
+ data: clonedFields,
921
+ counters: db.commands.counters,
922
+ });
881
923
  // 7. Atomic write. Try the candidate path; on ERR_PATH_CONFLICT
882
924
  // retry once with a 4-char UUID suffix.
883
925
  const defaultStatus = getDefaultStatus(definition);
@@ -49,6 +49,10 @@ function createMockDb() {
49
49
  softDeleteDocument,
50
50
  setOrderKey: vi.fn(),
51
51
  },
52
+ counters: {
53
+ ensureCounterGroup: vi.fn(),
54
+ nextCounterValue: vi.fn(),
55
+ },
52
56
  },
53
57
  queries: {
54
58
  collections: {
@@ -58,6 +58,10 @@ function createMockDb() {
58
58
  softDeleteDocument: vi.fn(),
59
59
  setOrderKey: vi.fn(),
60
60
  },
61
+ counters: {
62
+ ensureCounterGroup: vi.fn(),
63
+ nextCounterValue: vi.fn(),
64
+ },
61
65
  },
62
66
  queries: {
63
67
  collections: {
@@ -1,6 +1,8 @@
1
1
  export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, } from '../lib/errors.js';
2
2
  export { normaliseDateFields } from '../utils/normalise-dates.js';
3
+ export { type AssignCounterValuesInput, assignCounterValues } from './assign-counter-values.js';
3
4
  export { type CollectionRecord, type EnsureCollectionsInput, ensureCollections, } from './collection-bootstrap.js';
5
+ export { type DiscoverCounterGroupsInput, discoverCounterGroups, } from './discover-counter-groups.js';
4
6
  export * from './document-lifecycle.js';
5
7
  export * from './document-read.js';
6
8
  export * from './field-upload.js';
@@ -1,7 +1,9 @@
1
1
  // packages/core/src/services/index.ts
2
2
  export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, } from '../lib/errors.js';
3
3
  export { normaliseDateFields } from '../utils/normalise-dates.js';
4
+ export { assignCounterValues } from './assign-counter-values.js';
4
5
  export { ensureCollections, } from './collection-bootstrap.js';
6
+ export { discoverCounterGroups, } from './discover-counter-groups.js';
5
7
  export * from './document-lifecycle.js';
6
8
  export * from './document-read.js';
7
9
  export * from './field-upload.js';
@@ -146,6 +146,10 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
146
146
  softDeleteDocument: vi.fn(),
147
147
  setOrderKey: vi.fn(),
148
148
  },
149
+ counters: {
150
+ ensureCounterGroup: vi.fn(),
151
+ nextCounterValue: vi.fn(),
152
+ },
149
153
  },
150
154
  queries: {
151
155
  collections: {
@@ -38,6 +38,11 @@ function canonicalField(field) {
38
38
  if (field.mode !== undefined)
39
39
  base.mode = field.mode;
40
40
  return base;
41
+ case 'counter':
42
+ // Group name is load-bearing — renaming it picks a different
43
+ // sequence and is a real schema change worth fingerprinting.
44
+ base.group = field.group;
45
+ return base;
41
46
  case 'image':
42
47
  case 'file':
43
48
  // Per-field upload config affects the read-back contract (mime
@@ -33,6 +33,7 @@ export const fieldTypeToStore = {
33
33
  integer: { storeType: 'numeric', valueColumn: 'value_integer' },
34
34
  float: { storeType: 'numeric', valueColumn: 'value_float' },
35
35
  decimal: { storeType: 'numeric', valueColumn: 'value_decimal' },
36
+ counter: { storeType: 'numeric', valueColumn: 'value_integer' },
36
37
  // Boolean store
37
38
  boolean: { storeType: 'boolean', valueColumn: 'value' },
38
39
  checkbox: { storeType: 'boolean', valueColumn: 'value' },
@@ -22,6 +22,7 @@ const VALUE_FIELD_TYPES = [
22
22
  'integer',
23
23
  'float',
24
24
  'decimal',
25
+ 'counter',
25
26
  'boolean',
26
27
  'checkbox',
27
28
  'date',
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "2.1.3",
5
+ "version": "2.2.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -79,7 +79,7 @@
79
79
  "sharp": "^0.34.5",
80
80
  "uuid": "^14.0.0",
81
81
  "zod": "^4.4.3",
82
- "@byline/auth": "2.1.3"
82
+ "@byline/auth": "2.2.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",