@byline/core 6.8.0 → 6.10.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.
Files changed (37) hide show
  1. package/dist/@types/admin-types.d.ts +6 -6
  2. package/dist/@types/admin-types.js +3 -3
  3. package/dist/@types/form-admin-config.test.node.js +1 -1
  4. package/dist/@types/search-types.d.ts +34 -0
  5. package/dist/@types/search-types.js +2 -0
  6. package/dist/@types/search-types.test.node.d.ts +8 -0
  7. package/dist/@types/search-types.test.node.js +21 -0
  8. package/dist/@types/singleton-admin-config.test.node.js +8 -5
  9. package/dist/config/config.d.ts +2 -2
  10. package/dist/config/config.js +6 -6
  11. package/dist/config/group-collections.js +1 -1
  12. package/dist/config/group-collections.test.node.js +6 -6
  13. package/dist/config/validate-admin-configs.d.ts +1 -1
  14. package/dist/config/validate-admin-configs.js +10 -10
  15. package/dist/config/validate-admin-configs.test.node.js +22 -22
  16. package/dist/paths/path-dialects.test.node.js +2 -2
  17. package/dist/paths/paths.test.node.js +1 -1
  18. package/dist/services/document-lifecycle/copy-to-locale.js +2 -1
  19. package/dist/services/document-lifecycle/delete-locale.js +27 -0
  20. package/dist/services/document-lifecycle/internals.d.ts +3 -2
  21. package/dist/services/document-lifecycle/internals.js +3 -3
  22. package/dist/services/document-lifecycle/merge-write-locale.d.ts +12 -0
  23. package/dist/services/document-lifecycle/merge-write-locale.js +53 -0
  24. package/dist/services/document-lifecycle/persistence.d.ts +4 -1
  25. package/dist/services/document-lifecycle/persistence.js +23 -1
  26. package/dist/services/document-lifecycle/update.js +2 -2
  27. package/dist/services/document-lifecycle.test.node.js +4 -2
  28. package/dist/services/singleton-lifecycle/copy-to-locale.js +1 -1
  29. package/dist/services/singleton-lifecycle/update.js +1 -1
  30. package/dist/storage/index.d.ts +1 -1
  31. package/dist/storage/storage-flatten.js +8 -2
  32. package/dist/storage/storage-restore.d.ts +15 -2
  33. package/dist/storage/storage-restore.js +66 -15
  34. package/dist/storage/unavailable-blocks.test.node.d.ts +8 -0
  35. package/dist/storage/unavailable-blocks.test.node.js +132 -0
  36. package/dist/validation/document-fields.js +7 -4
  37. package/package.json +6 -6
@@ -251,14 +251,14 @@ export interface FormAdminConfig {
251
251
  * Admin UI configuration for a collection.
252
252
  * This is the presentation/UI layer — separate from the data schema.
253
253
  *
254
- * Linked to a `CollectionDefinition` by `slug` matching the collection's
254
+ * Linked to a `CollectionDefinition` by `path` matching the collection's
255
255
  * `path`.
256
256
  */
257
257
  export interface CollectionAdminConfig<T = any> extends FormAdminConfig {
258
258
  /** Explicit resource discriminator. Stamped by `defineAdmin`. */
259
259
  singleton?: false;
260
260
  /** Must match the `path` of the corresponding `CollectionDefinition`. */
261
- slug: string;
261
+ path: string;
262
262
  /**
263
263
  * Dashboard group this collection belongs to. Must name an entry in
264
264
  * `AdminConfig.collectionGroups`; an unknown name throws at startup.
@@ -447,7 +447,7 @@ export interface SingletonAdminConfig<T = any> extends FormAdminConfig {
447
447
  /** Explicit resource discriminator. Stamped by `defineSingletonAdmin`. */
448
448
  singleton: true;
449
449
  /** Must match the `path` of the corresponding `SingletonDefinition`. */
450
- slug: string;
450
+ path: string;
451
451
  /**
452
452
  * Dashboard group this resource belongs to. The registry remains named
453
453
  * `AdminConfig.collectionGroups` for backwards compatibility.
@@ -487,14 +487,14 @@ export interface ListActionComponentProps {
487
487
  }
488
488
  /**
489
489
  * Type-safe factory for creating a `CollectionAdminConfig` linked to a schema.
490
- * Sets `slug` from the schema's `path`.
490
+ * Sets `path` from the schema's `path`.
491
491
  */
492
- export declare function defineAdmin<T = any>(schema: MultiCollectionDefinition, config: Omit<CollectionAdminConfig<T>, 'slug' | 'singleton'>): CollectionAdminConfig<T>;
492
+ export declare function defineAdmin<T = any>(schema: MultiCollectionDefinition, config: Omit<CollectionAdminConfig<T>, 'path' | 'singleton'>): CollectionAdminConfig<T>;
493
493
  /**
494
494
  * Type-safe factory for a `SingletonAdminConfig` linked to a singleton schema.
495
495
  * Sets both the schema path and resource discriminator for authors.
496
496
  */
497
- export declare function defineSingletonAdmin<T = any>(schema: SingletonDefinition, config: Omit<SingletonAdminConfig<T>, 'slug' | 'singleton'>): SingletonAdminConfig<T>;
497
+ export declare function defineSingletonAdmin<T = any>(schema: SingletonDefinition, config: Omit<SingletonAdminConfig<T>, 'path' | 'singleton'>): SingletonAdminConfig<T>;
498
498
  /**
499
499
  * Admin UI configuration for a block — the block-scoped analogue of
500
500
  * `CollectionAdminConfig`, carrying **rendering overrides only**.
@@ -7,13 +7,13 @@
7
7
  */
8
8
  /**
9
9
  * Type-safe factory for creating a `CollectionAdminConfig` linked to a schema.
10
- * Sets `slug` from the schema's `path`.
10
+ * Sets `path` from the schema's `path`.
11
11
  */
12
12
  export function defineAdmin(schema, config) {
13
13
  return {
14
14
  ...config,
15
15
  singleton: false,
16
- slug: schema.path,
16
+ path: schema.path,
17
17
  };
18
18
  }
19
19
  /**
@@ -24,7 +24,7 @@ export function defineSingletonAdmin(schema, config) {
24
24
  return {
25
25
  ...config,
26
26
  singleton: true,
27
- slug: schema.path,
27
+ path: schema.path,
28
28
  };
29
29
  }
30
30
  /**
@@ -17,7 +17,7 @@ describe('FormAdminConfig', () => {
17
17
  });
18
18
  it('is satisfied by a full CollectionAdminConfig', () => {
19
19
  const collection = {
20
- slug: 'pages',
20
+ path: 'pages',
21
21
  layout: { main: [] },
22
22
  };
23
23
  const asForm = collection;
@@ -167,6 +167,24 @@ export interface SearchMatching {
167
167
  }
168
168
  /** Maximum UTF-16 length accepted for one full-text query. */
169
169
  export declare const MAX_SEARCH_QUERY_LENGTH = 1024;
170
+ /** Maximum `ceil((end - start) / gap)` for one range-facet request. */
171
+ export declare const MAX_RANGE_FACET_BUCKETS = 200;
172
+ /** One numeric range-facet request over an integer `search.filters` field. */
173
+ export interface SearchRangeFacetRequest {
174
+ field: string;
175
+ /** Inclusive lower edge of the first bucket. */
176
+ start: number;
177
+ /** Exclusive upper edge of the last bucket. */
178
+ end: number;
179
+ /** Bucket width; must be finite and greater than zero. */
180
+ gap: number;
181
+ }
182
+ /** One range bucket: `[start, end)` and its match count. */
183
+ export interface SearchRangeFacetBucket {
184
+ start: number;
185
+ end: number;
186
+ count: number;
187
+ }
170
188
  /**
171
189
  * A search request. Either collection-scoped (`collectionPath`) for
172
190
  * homogeneous results, or zone-scoped (`zone`) for heterogeneous
@@ -201,6 +219,8 @@ export interface SearchQuery {
201
219
  where?: QueryPredicate;
202
220
  /** Field names to compute facet buckets for (driver-capability gated). */
203
221
  facets?: string[];
222
+ /** Integer range aggregates; requires `capabilities.rangeFacets: true`. */
223
+ rangeFacets?: SearchRangeFacetRequest[];
204
224
  /** Restrict to a single content locale. */
205
225
  locale?: string;
206
226
  /**
@@ -254,6 +274,12 @@ export interface SearchResults {
254
274
  * driver supports them.
255
275
  */
256
276
  facets?: Record<string, SearchFacetBucket[]>;
277
+ /**
278
+ * Contiguous ascending buckets keyed by field, including zero counts.
279
+ * Cover exactly the requested `[start, end)`, truncating the final bucket.
280
+ * Counts respect the query, where, locale, status, and collection/zone scope.
281
+ */
282
+ rangeFacets?: Record<string, SearchRangeFacetBucket[]>;
257
283
  }
258
284
  /**
259
285
  * What a given driver can actually do. Lets consumers (the admin UI, the
@@ -266,6 +292,14 @@ export interface SearchResults {
266
292
  export interface SearchCapabilities {
267
293
  /** Faceted aggregation over indexed fields. */
268
294
  facets: boolean;
295
+ /** Integer range aggregation. Omission means false; unsupported requests must fail. */
296
+ rangeFacets?: boolean;
297
+ /**
298
+ * Applies `SearchQuery.where`. Omission means false. Advertise-only: the
299
+ * client still forwards `where` when absent. Providers may support a
300
+ * documented subset, but must reject unsupported fields or operators.
301
+ */
302
+ structuredFilters?: boolean;
269
303
  /** Fuzzy / typo-tolerant matching (e.g. `pg_trgm`). */
270
304
  typoTolerance: boolean;
271
305
  /** Vector / semantic / hybrid retrieval. */
@@ -7,3 +7,5 @@
7
7
  */
8
8
  /** Maximum UTF-16 length accepted for one full-text query. */
9
9
  export const MAX_SEARCH_QUERY_LENGTH = 1_024;
10
+ /** Maximum `ceil((end - start) / gap)` for one range-facet request. */
11
+ export const MAX_RANGE_FACET_BUCKETS = 200;
@@ -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,21 @@
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 { expect, it } from 'vitest';
9
+ import { MAX_RANGE_FACET_BUCKETS } from '../index.js';
10
+ it('exports the range-facet contract and bucket cap from the public core entry point', () => {
11
+ const request = { field: 'year', start: 2000, end: 2003, gap: 2 };
12
+ const buckets = [
13
+ { start: 2000, end: 2002, count: 0 },
14
+ { start: 2002, end: 2003, count: 1 },
15
+ ];
16
+ const query = { query: '', rangeFacets: [request] };
17
+ const result = { hits: [], total: 1, rangeFacets: { year: buckets } };
18
+ expect(MAX_RANGE_FACET_BUCKETS).toBe(200);
19
+ expect(query.rangeFacets).toEqual([request]);
20
+ expect(result.rangeFacets?.year).toEqual(buckets);
21
+ });
@@ -24,19 +24,22 @@ describe('singleton admin config', () => {
24
24
  const singletonAdmin = defineSingletonAdmin(settings, {});
25
25
  expect(collectionAdmin.singleton).toBe(false);
26
26
  expect(singletonAdmin.singleton).toBe(true);
27
- expect(singletonAdmin.slug).toBe(settings.path);
27
+ expect(collectionAdmin.path).toBe(pages.path);
28
+ expect(singletonAdmin.path).toBe(settings.path);
29
+ expect(collectionAdmin).not.toHaveProperty('slug');
30
+ expect(singletonAdmin).not.toHaveProperty('slug');
28
31
  });
29
32
  it('does not let untyped config input override factory-owned identity', () => {
30
33
  const collectionAdmin = defineAdmin(pages, {
31
34
  singleton: true,
32
- slug: 'wrong-collection',
35
+ path: 'wrong-collection',
33
36
  });
34
37
  const singletonAdmin = defineSingletonAdmin(settings, {
35
38
  singleton: false,
36
- slug: 'wrong-singleton',
39
+ path: 'wrong-singleton',
37
40
  });
38
- expect(collectionAdmin).toMatchObject({ singleton: false, slug: pages.path });
39
- expect(singletonAdmin).toMatchObject({ singleton: true, slug: settings.path });
41
+ expect(collectionAdmin).toMatchObject({ singleton: false, path: pages.path });
42
+ expect(singletonAdmin).toMatchObject({ singleton: true, path: settings.path });
40
43
  });
41
44
  it('shares the reusable form composition contract', () => {
42
45
  const singletonAdmin = defineSingletonAdmin(settings, {
@@ -11,8 +11,8 @@ import type { AdminConfig, CollectionAdminConfig, CollectionDefinition, Resolved
11
11
  * still throw the loud "Byline has not been configured" error.
12
12
  */
13
13
  export declare const getCollectionDefinition: (path: string) => CollectionDefinition | null;
14
- export declare const getCollectionAdminConfig: (slug: string) => CollectionAdminConfig | null;
15
- export declare const getSingletonAdminConfig: (slug: string) => SingletonAdminConfig | null;
14
+ export declare const getCollectionAdminConfig: (path: string) => CollectionAdminConfig | null;
15
+ export declare const getSingletonAdminConfig: (path: string) => SingletonAdminConfig | null;
16
16
  export declare function defineAdminConfig(config: AdminConfig): ResolvedAdminConfig;
17
17
  export declare function defineServerConfig<TAdminStore = unknown>(config: ServerConfig<TAdminStore>): ResolvedServerConfig<TAdminStore>;
18
18
  /** Resolve and validate server config without replacing the registered singleton. */
@@ -52,18 +52,18 @@ export const getCollectionDefinition = (path) => {
52
52
  return null;
53
53
  return config.collections.find((collection) => collection.path === path) ?? null;
54
54
  };
55
- const getAdminResourceConfig = (slug) => {
55
+ const getAdminResourceConfig = (path) => {
56
56
  const adminConfig = getAdminConfigInstance();
57
57
  if (adminConfig == null)
58
58
  return null;
59
- return adminConfig.admin?.find((admin) => admin.slug === slug) ?? null;
59
+ return adminConfig.admin?.find((admin) => admin.path === path) ?? null;
60
60
  };
61
- export const getCollectionAdminConfig = (slug) => {
62
- const admin = getAdminResourceConfig(slug);
61
+ export const getCollectionAdminConfig = (path) => {
62
+ const admin = getAdminResourceConfig(path);
63
63
  return admin?.singleton === true ? null : admin;
64
64
  };
65
- export const getSingletonAdminConfig = (slug) => {
66
- const admin = getAdminResourceConfig(slug);
65
+ export const getSingletonAdminConfig = (path) => {
66
+ const admin = getAdminResourceConfig(path);
67
67
  return admin?.singleton === true ? admin : null;
68
68
  };
69
69
  export function defineAdminConfig(config) {
@@ -33,7 +33,7 @@ export function groupCollectionsForAdmin(collections, admin, collectionGroups) {
33
33
  const groupByCollectionPath = new Map();
34
34
  for (const entry of admin ?? []) {
35
35
  if (entry.group != null)
36
- groupByCollectionPath.set(entry.slug, entry.group);
36
+ groupByCollectionPath.set(entry.path, entry.group);
37
37
  }
38
38
  const membersByGroup = new Map();
39
39
  for (const group of collectionGroups ?? []) {
@@ -29,9 +29,9 @@ const groups = [
29
29
  { name: 'taxonomy', label: 'Taxonomies' },
30
30
  ];
31
31
  const admin = [
32
- { slug: 'images', group: 'media' },
33
- { slug: 'authors', group: 'authorities' },
34
- { slug: 'categories', group: 'taxonomy' },
32
+ { path: 'images', group: 'media' },
33
+ { path: 'authors', group: 'authorities' },
34
+ { path: 'categories', group: 'taxonomy' },
35
35
  ];
36
36
  describe('groupCollectionsForAdmin', () => {
37
37
  it('returns one ungrouped bucket when no registry is declared', () => {
@@ -67,13 +67,13 @@ describe('groupCollectionsForAdmin', () => {
67
67
  });
68
68
  it('preserves collection declaration order within a bucket', () => {
69
69
  const more = define('videos');
70
- const result = groupCollectionsForAdmin([more, images], [...admin, { slug: 'videos', group: 'media' }], groups);
70
+ const result = groupCollectionsForAdmin([more, images], [...admin, { path: 'videos', group: 'media' }], groups);
71
71
  expect(result[0]?.collections).toEqual([more, images]);
72
72
  });
73
73
  it('groups singleton and multi-collection resources together in declaration order', () => {
74
74
  const resourceAdmin = [
75
75
  ...admin,
76
- { singleton: true, slug: settings.path, group: 'media' },
76
+ { singleton: true, path: settings.path, group: 'media' },
77
77
  ];
78
78
  const result = groupCollectionsForAdmin([images, settings], resourceAdmin, groups);
79
79
  expect(result[0]?.collections).toEqual([images, settings]);
@@ -85,7 +85,7 @@ describe('groupCollectionsForAdmin', () => {
85
85
  it('treats an undeclared group name as ungrouped rather than throwing', () => {
86
86
  // Boot validation rejects this configuration, but the function stays total
87
87
  // so a renderer can never crash on a stale or hand-built config object.
88
- const result = groupCollectionsForAdmin([pages], [{ slug: 'pages', group: 'ghost' }], groups);
88
+ const result = groupCollectionsForAdmin([pages], [{ path: 'pages', group: 'ghost' }], groups);
89
89
  expect(result).toEqual([{ name: null, label: null, collections: [pages] }]);
90
90
  });
91
91
  it('ignores admin configs whose collection is not registered', () => {
@@ -25,7 +25,7 @@ export declare function validateCollectionGroups(collectionGroups: readonly Coll
25
25
  * Validate every admin config in a configuration.
26
26
  *
27
27
  * Enforced rules (per admin config):
28
- * 1. Slug pairing — `admin.slug` matches exactly one `collection.path`.
28
+ * 1. Path pairing — `admin.path` matches exactly one `collection.path`.
29
29
  * 2. Placement — when `layout` is present, every top-level schema field
30
30
  * name appears exactly once across the union of all `tabSet.tabs[].fields`,
31
31
  * all `row.fields`, all `group.fields`, `layout.main`, and `layout.sidebar`.
@@ -91,14 +91,14 @@ export function validateCollectionGroups(collectionGroups, admins) {
91
91
  const known = declared.size === 0
92
92
  ? '`collectionGroups` was not declared, or is empty'
93
93
  : `declared groups: ${[...declared].map((name) => `"${name}"`).join(', ')}`;
94
- throw new Error(`Admin resource "${admin.slug}": \`group: '${admin.group}'\` does not name a declared collection group (${known}). Add it to \`AdminConfig.collectionGroups\`, or remove the \`group\` property.`);
94
+ throw new Error(`Admin config for path "${admin.path}": \`group: '${admin.group}'\` does not name a declared collection group (${known}). Add it to \`AdminConfig.collectionGroups\`, or remove the \`group\` property.`);
95
95
  }
96
96
  }
97
97
  /**
98
98
  * Validate every admin config in a configuration.
99
99
  *
100
100
  * Enforced rules (per admin config):
101
- * 1. Slug pairing — `admin.slug` matches exactly one `collection.path`.
101
+ * 1. Path pairing — `admin.path` matches exactly one `collection.path`.
102
102
  * 2. Placement — when `layout` is present, every top-level schema field
103
103
  * name appears exactly once across the union of all `tabSet.tabs[].fields`,
104
104
  * all `row.fields`, all `group.fields`, `layout.main`, and `layout.sidebar`.
@@ -210,24 +210,24 @@ export function validateBlockAdminConfigs(blockAdmins, collections) {
210
210
  }
211
211
  }
212
212
  function validateOne(admin, collectionsByPath) {
213
- // Rule 1 — slug pairing.
214
- const collection = collectionsByPath.get(admin.slug);
213
+ // Rule 1 — path pairing.
214
+ const collection = collectionsByPath.get(admin.path);
215
215
  if (collection == null) {
216
216
  if (admin.singleton === true) {
217
- throw new Error(`Singleton admin config "${admin.slug}" has no matching singleton definition (no singleton with \`path: '${admin.slug}'\` was registered).`);
217
+ throw new Error(`Singleton admin config "${admin.path}" has no matching singleton definition (no singleton with \`path: '${admin.path}'\` was registered).`);
218
218
  }
219
- throw new Error(`Admin config "${admin.slug}" has no matching collection (no collection with \`path: '${admin.slug}'\` was registered).`);
219
+ throw new Error(`Admin config "${admin.path}" has no matching collection (no collection with \`path: '${admin.path}'\` was registered).`);
220
220
  }
221
221
  if (admin.singleton === true && !isSingleton(collection)) {
222
- throw new Error(`Singleton admin config "${admin.slug}" targets a collection definition. Register it with \`defineAdmin\`, or point it at a singleton definition.`);
222
+ throw new Error(`Singleton admin config "${admin.path}" targets a collection definition. Register it with \`defineAdmin\`, or point it at a singleton definition.`);
223
223
  }
224
224
  if (admin.singleton !== true && isSingleton(collection)) {
225
- throw new Error(`Collection admin config "${admin.slug}" targets a singleton definition. Register it with \`defineSingletonAdmin\`, or point it at a multi-collection definition.`);
225
+ throw new Error(`Collection admin config "${admin.path}" targets a singleton definition. Register it with \`defineSingletonAdmin\`, or point it at a multi-collection definition.`);
226
226
  }
227
- const slug = admin.slug;
227
+ const path = admin.path;
228
228
  const fail = (msg) => {
229
229
  const resource = admin.singleton === true ? 'Singleton' : 'Collection';
230
- throw new Error(`${resource} "${slug}": ${msg}`);
230
+ throw new Error(`${resource} "${path}": ${msg}`);
231
231
  };
232
232
  if (admin.singleton === true) {
233
233
  for (const key of COLLECTION_ONLY_ADMIN_KEYS) {
@@ -21,7 +21,7 @@ const collection = {
21
21
  ],
22
22
  };
23
23
  const baseAdmin = {
24
- slug: 'news',
24
+ path: 'news',
25
25
  tabSets: [
26
26
  {
27
27
  name: 'main',
@@ -80,20 +80,20 @@ describe('validateAdminConfigs', () => {
80
80
  expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/lockPath.*not allowed on a singleton admin config/);
81
81
  });
82
82
  it('rejects a singleton admin config targeting a multi-collection', () => {
83
- const admin = { ...defineSingletonAdmin(singleton, {}), slug: collection.path };
83
+ const admin = { ...defineSingletonAdmin(singleton, {}), path: collection.path };
84
84
  expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/targets a collection definition/);
85
85
  });
86
86
  it('rejects a singleton admin config with no matching definition', () => {
87
- const admin = { ...defineSingletonAdmin(singleton, {}), slug: 'missing-settings' };
87
+ const admin = { ...defineSingletonAdmin(singleton, {}), path: 'missing-settings' };
88
88
  expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/no matching singleton definition/);
89
89
  });
90
90
  it('rejects a multi-collection admin config targeting a singleton', () => {
91
- const admin = { slug: singleton.path };
91
+ const admin = { path: singleton.path };
92
92
  expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/targets a singleton definition/);
93
93
  });
94
- // Rule 1 — slug pairing.
95
- it('rejects an admin config whose slug has no matching collection', () => {
96
- const orphan = { slug: 'nonexistent', layout: { main: [] } };
94
+ // Rule 1 — path pairing.
95
+ it('rejects an admin config whose path has no matching collection', () => {
96
+ const orphan = { path: 'nonexistent', layout: { main: [] } };
97
97
  expect(() => validateAdminConfigs([orphan], [collection])).toThrow(/no matching collection/);
98
98
  });
99
99
  // Rule 2 — placement bookkeeping.
@@ -177,7 +177,7 @@ describe('validateAdminConfigs', () => {
177
177
  // Rule 5 — nesting.
178
178
  it('rejects a row whose fields include a non-field primitive', () => {
179
179
  const admin = {
180
- slug: 'news',
180
+ path: 'news',
181
181
  tabSets: [
182
182
  {
183
183
  name: 'main',
@@ -198,7 +198,7 @@ describe('validateAdminConfigs', () => {
198
198
  });
199
199
  it('rejects a tab set referenced from inside a tab', () => {
200
200
  const admin = {
201
- slug: 'news',
201
+ path: 'news',
202
202
  tabSets: [
203
203
  {
204
204
  name: 'inner',
@@ -226,7 +226,7 @@ describe('validateAdminConfigs', () => {
226
226
  });
227
227
  it('rejects a group nested inside another group', () => {
228
228
  const admin = {
229
- slug: 'news',
229
+ path: 'news',
230
230
  tabSets: [
231
231
  {
232
232
  name: 'main',
@@ -290,7 +290,7 @@ describe('validateAdminConfigs', () => {
290
290
  ],
291
291
  };
292
292
  const nestedAdmin = (fields) => ({
293
- slug: 'library',
293
+ path: 'library',
294
294
  fields: fields,
295
295
  });
296
296
  it('accepts a dotted fields key through array and group declarations', () => {
@@ -386,14 +386,14 @@ describe('validateAdminConfigs', () => {
386
386
  // Layout omitted — bookkeeping skipped, primitive declarations still checked.
387
387
  it('skips placement bookkeeping when layout is omitted', () => {
388
388
  const admin = {
389
- slug: 'news',
389
+ path: 'news',
390
390
  // No layout — renderer will synthesise a default.
391
391
  };
392
392
  expect(() => validateAdminConfigs([admin], [collection])).not.toThrow();
393
393
  });
394
394
  it('still rejects a primitive collision when layout is omitted', () => {
395
395
  const admin = {
396
- slug: 'news',
396
+ path: 'news',
397
397
  rows: [
398
398
  { name: 'titles', fields: ['text1', 'text2'] },
399
399
  // 'title' is also a schema field — collision still caught.
@@ -518,34 +518,34 @@ describe('validateAdminConfigs — collection groups', () => {
518
518
  { name: 'taxonomy', label: 'Taxonomies' },
519
519
  ];
520
520
  it('accepts a valid registry and a valid reference', () => {
521
- expect(() => validateAdminConfigs([{ slug: 'news', group: 'media' }], [collection], groups)).not.toThrow();
521
+ expect(() => validateAdminConfigs([{ path: 'news', group: 'media' }], [collection], groups)).not.toThrow();
522
522
  });
523
523
  it('accepts a collection with no group when a registry is declared', () => {
524
- expect(() => validateAdminConfigs([{ slug: 'news' }], [collection], groups)).not.toThrow();
524
+ expect(() => validateAdminConfigs([{ path: 'news' }], [collection], groups)).not.toThrow();
525
525
  });
526
526
  it('is a no-op when no registry and no group references are present', () => {
527
- expect(() => validateAdminConfigs([{ slug: 'news' }], [collection])).not.toThrow();
527
+ expect(() => validateAdminConfigs([{ path: 'news' }], [collection])).not.toThrow();
528
528
  });
529
529
  it('rejects a duplicate group name', () => {
530
- expect(() => validateAdminConfigs([{ slug: 'news' }], [collection], [
530
+ expect(() => validateAdminConfigs([{ path: 'news' }], [collection], [
531
531
  { name: 'media', label: 'Media' },
532
532
  { name: 'media', label: 'Media Library' },
533
533
  ])).toThrow(/declared more than once/);
534
534
  });
535
535
  it('rejects a blank group name', () => {
536
- expect(() => validateAdminConfigs([{ slug: 'news' }], [collection], [{ name: ' ', label: 'Media' }])).toThrow(/blank `name`/);
536
+ expect(() => validateAdminConfigs([{ path: 'news' }], [collection], [{ name: ' ', label: 'Media' }])).toThrow(/blank `name`/);
537
537
  });
538
538
  it('rejects a blank group label', () => {
539
- expect(() => validateAdminConfigs([{ slug: 'news' }], [collection], [{ name: 'media', label: '' }])).toThrow(/blank `label`/);
539
+ expect(() => validateAdminConfigs([{ path: 'news' }], [collection], [{ name: 'media', label: '' }])).toThrow(/blank `label`/);
540
540
  });
541
541
  it('rejects a group reference that names no declared group', () => {
542
- expect(() => validateAdminConfigs([{ slug: 'news', group: 'medai' }], [collection], groups)).toThrow(/does not name a declared collection group/);
542
+ expect(() => validateAdminConfigs([{ path: 'news', group: 'medai' }], [collection], groups)).toThrow(/does not name a declared collection group/);
543
543
  });
544
544
  it('names the declared groups in the unresolved-reference error', () => {
545
- expect(() => validateAdminConfigs([{ slug: 'news', group: 'medai' }], [collection], groups)).toThrow(/"media", "taxonomy"/);
545
+ expect(() => validateAdminConfigs([{ path: 'news', group: 'medai' }], [collection], groups)).toThrow(/"media", "taxonomy"/);
546
546
  });
547
547
  it('rejects a group reference when no registry was declared at all', () => {
548
- expect(() => validateAdminConfigs([{ slug: 'news', group: 'media' }], [collection])).toThrow(/was not declared/);
548
+ expect(() => validateAdminConfigs([{ path: 'news', group: 'media' }], [collection])).toThrow(/was not declared/);
549
549
  });
550
550
  // Registry sanity must not be skipped by the `admins` early return.
551
551
  it('validates the registry even when there are no admin configs', () => {
@@ -139,7 +139,7 @@ describe('path dialect — upload hook registry keys', () => {
139
139
  // one registration applies wherever the block renders.
140
140
  // ---------------------------------------------------------------------------
141
141
  describe('path dialect — admin fields{} override keys', () => {
142
- const admin = (key) => ({ slug: 'pages', fields: { [key]: {} } });
142
+ const admin = (key) => ({ path: 'pages', fields: { [key]: {} } });
143
143
  const validate = (key) => () => validateAdminConfigs([admin(key)], [fixture()]);
144
144
  it('accepts a top-level field name', () => {
145
145
  expect(validate('title')).not.toThrow();
@@ -262,6 +262,6 @@ describe('path dialects — cross-dialect relationships', () => {
262
262
  collections: [fixture()],
263
263
  hooks: { uploads: { [registryKey]: async () => ({}) } },
264
264
  })).not.toThrow();
265
- expect(() => validateAdminConfigs([{ slug: 'pages', fields: { [adminKey]: {} } }], [fixture()])).not.toThrow();
265
+ expect(() => validateAdminConfigs([{ path: 'pages', fields: { [adminKey]: {} } }], [fixture()])).not.toThrow();
266
266
  });
267
267
  });
@@ -309,7 +309,7 @@ describe('resolveDeclarationPath — agreement with the admin fields{} validator
309
309
  };
310
310
  const validatorAccepts = (key) => {
311
311
  try {
312
- validateAdminConfigs([{ slug: 'pages', fields: { [key]: {} } }], [collection]);
312
+ validateAdminConfigs([{ path: 'pages', fields: { [key]: {} } }], [collection]);
313
313
  return true;
314
314
  }
315
315
  catch {
@@ -104,6 +104,7 @@ export async function copyToLocale(ctx, params) {
104
104
  const committed = await commitGuardedDocumentMutation(ctx, { ...params, previousVersionId, locale: params.targetLocale }, async () => {
105
105
  if ((targetRecord._availableVersionLocales?.includes(params.targetLocale) ||
106
106
  targetRecord._localeAgnostic === true) &&
107
+ !targetRecord.unavailableBlocks?.length &&
107
108
  sameDocumentData(merged.data, baseline))
108
109
  return { value: previousVersionId, changed: false };
109
110
  const writeResult = await persistExistingDocumentVersion(ctx, {
@@ -113,7 +114,7 @@ export async function copyToLocale(ctx, params) {
113
114
  status: getDefaultStatus(definition),
114
115
  locale: params.targetLocale,
115
116
  previousVersionId,
116
- });
117
+ }, targetRecord);
117
118
  return { value: extractVersionId(writeResult.document), changed: true };
118
119
  });
119
120
  const documentVersionId = committed.value;
@@ -9,6 +9,8 @@ import { resolveHooks } from '../../@types/index.js';
9
9
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
10
  import { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
+ import { flattenFieldSetData } from '../../storage/storage-flatten.js';
13
+ import { restoreFieldSetData } from '../../storage/storage-restore.js';
12
14
  import { getDefaultStatus } from '../../workflow/workflow.js';
13
15
  import { runCommittedDocumentHook } from './committed-hook.js';
14
16
  import { actorId, invokeHook } from './internals.js';
@@ -84,6 +86,31 @@ export async function deleteLocale(ctx, params) {
84
86
  ctx,
85
87
  documentId: params.documentId,
86
88
  write: async () => {
89
+ // The ordinary delete-locale primitive copies raw stored rows.
90
+ // Reconstruct affected versions so unavailable blocks are omitted too.
91
+ if (targetRecord.unavailableBlocks?.length) {
92
+ const previous = await db.queries.documents.getDocumentByVersion({
93
+ document_version_id: previousVersionId,
94
+ document_id: params.documentId,
95
+ collection_id: collectionId,
96
+ locale: 'all',
97
+ });
98
+ const rows = flattenFieldSetData(definition.fields, previous.fields, 'all').filter((row) => row.locale !== params.locale);
99
+ const { data } = restoreFieldSetData(definition.fields, rows);
100
+ const saved = await db.commands.documents.createDocumentVersion({
101
+ documentId: params.documentId,
102
+ collectionId,
103
+ collectionVersion: ctx.collectionVersion,
104
+ collectionConfig: definition,
105
+ action: 'delete_locale',
106
+ documentData: data,
107
+ locale: 'all',
108
+ previousVersionId,
109
+ status: getDefaultStatus(definition),
110
+ createdBy: actorId(ctx),
111
+ });
112
+ return { newVersionId: saved.document.id, previousVersionId };
113
+ }
87
114
  const deleted = await db.commands.documents.deleteDocumentLocale({
88
115
  documentId: params.documentId,
89
116
  locale: params.locale,
@@ -112,8 +112,9 @@ export declare function resolvePathForUpdate(args: {
112
112
  */
113
113
  export declare function derivePath(definition: CollectionDefinition, data: Record<string, any>, defaultLocale: string, slugifier: SlugifierFn): string;
114
114
  /**
115
- * Strip the synthetic `_id` / `_type` meta keys from every block and
116
- * array-item node in a reconstructed document tree.
115
+ * Strip the synthetic `_id` identity from every block and
116
+ * array-item node in a reconstructed document tree. Preserve `_type`, which
117
+ * selects the schema used to flatten each surviving block.
117
118
  *
118
119
  * Reconstructed `locale: 'all'` trees carry stable `_id` values for
119
120
  * blocks and array items (see CLAUDE.md → "Block/array items carry a
@@ -232,8 +232,9 @@ export function derivePath(definition, data, defaultLocale, slugifier) {
232
232
  return crypto.randomUUID();
233
233
  }
234
234
  /**
235
- * Strip the synthetic `_id` / `_type` meta keys from every block and
236
- * array-item node in a reconstructed document tree.
235
+ * Strip the synthetic `_id` identity from every block and
236
+ * array-item node in a reconstructed document tree. Preserve `_type`, which
237
+ * selects the schema used to flatten each surviving block.
237
238
  *
238
239
  * Reconstructed `locale: 'all'` trees carry stable `_id` values for
239
240
  * blocks and array items (see CLAUDE.md → "Block/array items carry a
@@ -254,7 +255,6 @@ export function stripMetaIdsInPlace(value) {
254
255
  if (value != null && typeof value === 'object' && !(value instanceof Date)) {
255
256
  const obj = value;
256
257
  delete obj._id;
257
- delete obj._type;
258
258
  for (const key of Object.keys(obj)) {
259
259
  stripMetaIdsInPlace(obj[key]);
260
260
  }
@@ -0,0 +1,12 @@
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
+ /** Preserve other locales by item identity after unavailable blocks were compacted.
10
+ * Incoming structure is authoritative; never carry old positional store rows forward.
11
+ */
12
+ export declare function mergeWriteLocale(fields: FieldSet, previous: Record<string, any>, incoming: Record<string, any>, locale: string): Record<string, any>;