@byline/core 2.1.3 → 2.2.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.
- package/dist/@types/db-types.d.ts +43 -0
- package/dist/@types/field-data-types.d.ts +1 -0
- package/dist/@types/field-types.d.ts +50 -1
- package/dist/core.js +12 -0
- package/dist/schemas/zod/builder.js +10 -1
- package/dist/services/assign-counter-values.d.ts +61 -0
- package/dist/services/assign-counter-values.js +110 -0
- package/dist/services/assign-counter-values.test.node.d.ts +8 -0
- package/dist/services/assign-counter-values.test.node.js +163 -0
- package/dist/services/collection-bootstrap.test.node.js +8 -0
- package/dist/services/discover-counter-groups.d.ts +31 -0
- package/dist/services/discover-counter-groups.js +105 -0
- package/dist/services/discover-counter-groups.test.node.d.ts +8 -0
- package/dist/services/discover-counter-groups.test.node.js +183 -0
- package/dist/services/document-lifecycle.js +43 -1
- package/dist/services/document-lifecycle.test.node.js +4 -0
- package/dist/services/field-upload.test.node.js +4 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +2 -0
- package/dist/services/populate.test.node.js +4 -0
- package/dist/storage/collection-fingerprint.js +5 -0
- package/dist/storage/field-store-map.js +1 -0
- package/dist/storage/field-store-map.test.node.js +1 -0
- package/package.json +2 -2
|
@@ -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
|
|
@@ -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.
|
|
@@ -188,7 +199,7 @@ export async function createDocument(ctx, params) {
|
|
|
188
199
|
action: 'create',
|
|
189
200
|
documentData: data,
|
|
190
201
|
path: resolvedPath,
|
|
191
|
-
status: params.status ?? data.status,
|
|
202
|
+
status: params.status ?? data.status ?? getDefaultStatus(definition),
|
|
192
203
|
locale: params.locale ?? defaultLocale,
|
|
193
204
|
orderKey,
|
|
194
205
|
})
|
|
@@ -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);
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/services/index.js
CHANGED
|
@@ -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' },
|
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
|
|
5
|
+
"version": "2.2.1",
|
|
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
|
|
82
|
+
"@byline/auth": "2.2.1"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|