@chelone/core 0.1.1 → 0.3.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.
@@ -1,20 +1,17 @@
1
+ import { getAtPath } from "../resource/get-at-path.js";
1
2
  import { calendarDayKey, parseCalendarDate } from "./calendar-dates.js";
2
- const readPath = (record, path) => path.split(".").reduce(
3
- (value, key) => value == null ? void 0 : value[key],
4
- record
5
- );
6
3
  const midnight = (date, dayOffset = 0) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + dayOffset);
7
4
  const isMidnight = (date) => date.getTime() === midnight(date).getTime();
8
5
  function toCalendarEvents(records, options) {
9
6
  const events = [];
10
7
  for (const record of records) {
11
- const start = parseCalendarDate(readPath(record, options.startSource));
8
+ const start = parseCalendarDate(getAtPath(record, options.startSource));
12
9
  if (!start) continue;
13
- const parsedEnd = options.endSource ? parseCalendarDate(readPath(record, options.endSource)) : void 0;
10
+ const parsedEnd = options.endSource ? parseCalendarDate(getAtPath(record, options.endSource)) : void 0;
14
11
  const end = parsedEnd && parsedEnd.getTime() >= start.getTime() ? parsedEnd : void 0;
15
- const titleValue = options.titleSource ? readPath(record, options.titleSource) : void 0;
12
+ const titleValue = options.titleSource ? getAtPath(record, options.titleSource) : void 0;
16
13
  const title = (typeof titleValue === "string" || typeof titleValue === "number") && titleValue !== "" ? String(titleValue) : options.getTitle(record);
17
- const colorValue = options.colorSource ? readPath(record, options.colorSource) : void 0;
14
+ const colorValue = options.colorSource ? getAtPath(record, options.colorSource) : void 0;
18
15
  const color = colorValue == null ? void 0 : options.colorMap?.[String(colorValue)];
19
16
  const event = { record, start, title };
20
17
  if (end) event.end = end;
@@ -1,3 +1,4 @@
1
+ import { getAtPath } from "../resource/get-at-path.js";
1
2
  import { createMicroStore } from "../store/micro-store.js";
2
3
  import { removeFilterPath, listParamsFromPatch } from "./list-controller.js";
3
4
  const DEFAULT_PER_PAGE = 1e3;
@@ -15,16 +16,6 @@ function removeEmpty(filter) {
15
16
  }
16
17
  return result;
17
18
  }
18
- function getByPath(record, path) {
19
- if (record == null) return void 0;
20
- if (!path.includes(".")) return record[path];
21
- let current = record;
22
- for (const segment of path.split(".")) {
23
- if (current == null || typeof current !== "object") return void 0;
24
- current = current[segment];
25
- }
26
- return current;
27
- }
28
19
  function flattenFilter(filter, prefix = "") {
29
20
  const result = {};
30
21
  for (const [key, value] of Object.entries(filter)) {
@@ -39,7 +30,7 @@ function flattenFilter(filter, prefix = "") {
39
30
  }
40
31
  function matchesFilter(record, flatFilter) {
41
32
  return Object.entries(flatFilter).every(([filterName, filterValue]) => {
42
- const recordValue = getByPath(record, filterName);
33
+ const recordValue = getAtPath(record, filterName);
43
34
  if (Array.isArray(recordValue)) {
44
35
  return Array.isArray(filterValue) ? recordValue.some((item) => filterValue.includes(item)) : recordValue.includes(filterValue);
45
36
  }
@@ -67,8 +58,8 @@ function applyFilterAndSort(records, filter, sort, filterCallback) {
67
58
  result = [...result].sort((a, b) => {
68
59
  for (const { field, order } of keys) {
69
60
  const asc = order === "ASC";
70
- const av = getByPath(a, field);
71
- const bv = getByPath(b, field);
61
+ const av = getAtPath(a, field);
62
+ const bv = getAtPath(b, field);
72
63
  if (av === bv || av == null && bv == null) continue;
73
64
  if (av == null) return asc ? -1 : 1;
74
65
  if (bv == null) return asc ? 1 : -1;
@@ -1,7 +1,4 @@
1
- const readPath = (record, path) => path.split(".").reduce(
2
- (value, key) => value == null ? void 0 : value[key],
3
- record
4
- );
1
+ import { getAtPath } from "../resource/get-at-path.js";
5
2
  function buildRecordTree(records, parentSource) {
6
3
  const nodes = /* @__PURE__ */ new Map();
7
4
  const order = /* @__PURE__ */ new Map();
@@ -13,7 +10,7 @@ function buildRecordTree(records, parentSource) {
13
10
  }
14
11
  const parents = /* @__PURE__ */ new Map();
15
12
  for (const [id, node] of nodes) {
16
- const value = readPath(node.record, parentSource);
13
+ const value = getAtPath(node.record, parentSource);
17
14
  const parent = value == null || value === "" ? null : String(value);
18
15
  parents.set(
19
16
  id,
@@ -7,21 +7,37 @@
7
7
  * can overwrite each other. The server has to enforce the count and who may
8
8
  * approve.
9
9
  */
10
+ import type { SourceKey } from "../resource/source-path.js";
10
11
  type Id = string | number;
11
- export interface ApprovalOptions {
12
- /** Record field holding the approver list. Default `"approvers"`. */
13
- approverSource?: string;
12
+ export interface ApprovalOptions<TRecord = Record<string, unknown>> {
13
+ /**
14
+ * Record field holding the approver list, a top-level key: it is read as
15
+ * `record[approverSource]` and written back whole. Default `"approvers"`.
16
+ */
17
+ approverSource?: SourceKey<TRecord>;
14
18
  /** Approvals needed. Floored, minimum 1; `NaN` reads as 2. Default 2. */
15
19
  required?: number;
16
20
  /** The current user. No identity means nobody can approve. */
17
21
  identityId?: Id | null;
18
- /** Record field `buildApprovalUpdate` writes the status to. Default `"status"`. */
19
- statusSource?: string;
22
+ /**
23
+ * Record field `buildApprovalUpdate` writes the status to, a top-level key.
24
+ * Default `"status"`.
25
+ */
26
+ statusSource?: SourceKey<TRecord>;
20
27
  /** Status written while approvals are short of `required`. Default `"pending"`. */
21
28
  pendingValue?: unknown;
22
29
  /** Status written once approvals reach `required`. Default `"approved"`. */
23
30
  approvedValue?: unknown;
24
31
  }
32
+ /**
33
+ * What `buildApprovalUpdate` takes. `statusSource` is a write target, and a
34
+ * record often has no status field until its first approval, so it takes any
35
+ * string; `approverSource` is read, so it stays a key of the record.
36
+ */
37
+ export type ApprovalUpdateOptions<TRecord = Record<string, unknown>> = Omit<ApprovalOptions<TRecord>, "statusSource"> & {
38
+ /** Record field the status is written to. Default `"status"`. */
39
+ statusSource?: string;
40
+ };
25
41
  export interface ApprovalState {
26
42
  /** Distinct approver ids, as strings. */
27
43
  approvers: string[];
@@ -31,9 +47,9 @@ export interface ApprovalState {
31
47
  isComplete: boolean;
32
48
  canApprove: boolean;
33
49
  }
34
- export interface TransitionOptions {
35
- /** Record field holding the status. Default `"status"`. */
36
- source?: string;
50
+ export interface TransitionOptions<TRecord = Record<string, unknown>> {
51
+ /** Record field holding the status, a top-level key. Default `"status"`. */
52
+ source?: SourceKey<TRecord>;
37
53
  /** Next states by current state. */
38
54
  transitions: Record<string, readonly string[]>;
39
55
  /** Predicates keyed `"<from>-><to>"`. A transition with no guard is allowed. */
@@ -43,12 +59,12 @@ export interface AllowedTransitions {
43
59
  current: string;
44
60
  allowed: string[];
45
61
  }
46
- export declare function getApprovalState(record: Record<string, unknown>, options?: ApprovalOptions): ApprovalState;
62
+ export declare function getApprovalState<TRecord = Record<string, unknown>>(record: TRecord & object, options?: ApprovalOptions<TRecord>): ApprovalState;
47
63
  /**
48
64
  * The patch that records `identityId`'s approval, or `null` when they cannot
49
65
  * approve. Stored items stay as they were and the identity is appended in its
50
66
  * own type.
51
67
  */
52
- export declare function buildApprovalUpdate(record: Record<string, unknown>, options?: ApprovalOptions): Record<string, unknown> | null;
53
- export declare function getAllowedTransitions(record: Record<string, unknown>, options: TransitionOptions): AllowedTransitions;
68
+ export declare function buildApprovalUpdate<TRecord = Record<string, unknown>>(record: TRecord & object, options?: ApprovalUpdateOptions<TRecord>): Record<string, unknown> | null;
69
+ export declare function getAllowedTransitions<TRecord = Record<string, unknown>>(record: TRecord & object, options: TransitionOptions<TRecord>): AllowedTransitions;
54
70
  export {};
@@ -34,7 +34,11 @@ function buildApprovalUpdate(record, options = {}) {
34
34
  pendingValue = "pending",
35
35
  approvedValue = "approved"
36
36
  } = options;
37
- const state = getApprovalState(record, options);
37
+ const state = getApprovalState(record, {
38
+ approverSource: options.approverSource,
39
+ required: options.required,
40
+ identityId
41
+ });
38
42
  if (!state.canApprove || identityId === void 0 || identityId === null) {
39
43
  return null;
40
44
  }
@@ -12,6 +12,14 @@ export declare function removeRecordsFromCache(queryClient: QueryClient, resourc
12
12
  * `getManyReference`, `getInfiniteList` page and `getMany` entry.
13
13
  */
14
14
  export declare function updateRecordsInCache(queryClient: QueryClient, resource: string, ids: Identifier[], data: Record<string, unknown>): void;
15
+ /**
16
+ * Shallow-merge `data` into the matching rows of every cached `getList`,
17
+ * `getManyReference` and `getInfiniteList` page, and return a `rollback` that
18
+ * puts back only the patched keys of those rows into whatever each entry holds
19
+ * when it is called, so another update's patch to a different row survives.
20
+ * An entry that holds none of the ids keeps its cached object.
21
+ */
22
+ export declare function patchListEntries(queryClient: QueryClient, resource: string, idSet: Set<string>, data: Record<string, unknown>): () => void;
15
23
  /**
16
24
  * Shallow-merge `data` into the matching records of every cached `getMany`
17
25
  * entry (what `ReferenceField` and `ReferenceArrayField` read through).
@@ -95,6 +95,85 @@ function updateRecordsInCache(queryClient, resource, ids, data) {
95
95
  }
96
96
  patchManyEntries(queryClient, resource, idSet, data);
97
97
  }
98
+ const ABSENT = /* @__PURE__ */ Symbol("absent");
99
+ function priorsOf(page, idSet, data) {
100
+ const priors = /* @__PURE__ */ new Map();
101
+ for (const row of page.data) {
102
+ const id = String(row.id);
103
+ if (!idSet.has(id)) continue;
104
+ const prior = {};
105
+ for (const field of Object.keys(data)) {
106
+ prior[field] = field in row ? row[field] : ABSENT;
107
+ }
108
+ priors.set(id, prior);
109
+ }
110
+ return priors;
111
+ }
112
+ function withRestoredRows(page, priors) {
113
+ return {
114
+ ...page,
115
+ data: page.data.map((record) => {
116
+ const prior = priors.get(String(record.id));
117
+ if (!prior) return record;
118
+ const restored = { ...record };
119
+ for (const [field, value] of Object.entries(prior)) {
120
+ if (value === ABSENT) delete restored[field];
121
+ else restored[field] = value;
122
+ }
123
+ return restored;
124
+ })
125
+ };
126
+ }
127
+ function patchListEntries(queryClient, resource, idSet, data) {
128
+ const rollbacks = [];
129
+ for (const queryKey of singleResultListKeyPrefixes(resource)) {
130
+ for (const [key, value] of queryClient.getQueriesData({
131
+ queryKey
132
+ })) {
133
+ if (!value) continue;
134
+ const next = withPatchedRows(value, idSet, data);
135
+ if (!next) continue;
136
+ const priors = priorsOf(value, idSet, data);
137
+ queryClient.setQueryData(key, next);
138
+ rollbacks.push(() => {
139
+ const current = queryClient.getQueryData(key);
140
+ if (current) {
141
+ queryClient.setQueryData(
142
+ key,
143
+ withRestoredRows(current, priors)
144
+ );
145
+ }
146
+ });
147
+ }
148
+ }
149
+ const infinite = queryClient.getQueriesData({ queryKey: infiniteListKeyPrefix(resource) });
150
+ for (const [key, value] of infinite) {
151
+ if (!value) continue;
152
+ const pages = value.pages.map(
153
+ (page) => withPatchedRows(page, idSet, data) ?? page
154
+ );
155
+ if (pages.every((page, i) => page === value.pages[i])) continue;
156
+ const priorsByPage = value.pages.map((page) => priorsOf(page, idSet, data));
157
+ queryClient.setQueryData(key, {
158
+ ...value,
159
+ pages
160
+ });
161
+ rollbacks.push(() => {
162
+ const current = queryClient.getQueryData(key);
163
+ if (!current) return;
164
+ queryClient.setQueryData(key, {
165
+ ...current,
166
+ pages: current.pages.map((page, i) => {
167
+ const priors = priorsByPage[i];
168
+ return priors && priors.size > 0 ? withRestoredRows(page, priors) : page;
169
+ })
170
+ });
171
+ });
172
+ }
173
+ return () => {
174
+ for (const rollback of rollbacks) rollback();
175
+ };
176
+ }
98
177
  function patchManyEntries(queryClient, resource, idSet, data) {
99
178
  const manys = queryClient.getQueriesData({
100
179
  queryKey: manyKeyPrefix(resource)
@@ -125,6 +204,7 @@ function removeManyEntries(queryClient, resource, idSet) {
125
204
  }
126
205
  }
127
206
  export {
207
+ patchListEntries,
128
208
  patchManyEntries,
129
209
  removeManyEntries,
130
210
  removeRecordsFromCache,
@@ -16,17 +16,20 @@ interface OptimisticContext {
16
16
  previous?: unknown;
17
17
  key?: readonly unknown[];
18
18
  manySnapshot?: ManySnapshot;
19
+ rollbackLists?: () => void;
19
20
  }
20
21
  export declare function createMutationOptions<TResources extends ResourceMap, TName extends keyof TResources & string>(ctx: DataContext<TResources>, resource: TName): {
21
22
  mutationFn: (params: CreateParams<RecordOf<TResources, TName>>) => Promise<RecordOf<TResources, TName>>;
22
23
  onSettled: () => Promise<void>;
23
24
  };
24
25
  export declare function updateMutationOptions<TResources extends ResourceMap, TName extends keyof TResources & string>(ctx: DataContext<TResources>, resource: TName, options?: MutationModeOptions): {
26
+ mutationKey: string[];
25
27
  mutationFn: (params: UpdateParams<RecordOf<TResources, TName>>) => Promise<RecordOf<TResources, TName>>;
26
28
  onMutate: (params: UpdateParams<RecordOf<TResources, TName>>) => Promise<{
27
29
  previous?: undefined;
28
30
  key?: undefined;
29
31
  manySnapshot?: undefined;
32
+ rollbackLists?: undefined;
30
33
  } | {
31
34
  previous: RecordOf<TResources, TName> | undefined;
32
35
  key: readonly [string, "getOne", {
@@ -37,9 +40,10 @@ export declare function updateMutationOptions<TResources extends ResourceMap, TN
37
40
  meta: {} | null;
38
41
  }];
39
42
  manySnapshot: ManySnapshot;
43
+ rollbackLists: () => void;
40
44
  }>;
41
45
  onError: (_error: unknown, _params: UpdateParams<RecordOf<TResources, TName>>, context: OptimisticContext | undefined) => void;
42
- onSettled: () => Promise<void>;
46
+ onSettled: () => Promise<void> | undefined;
43
47
  };
44
48
  export declare function deleteMutationOptions<TResources extends ResourceMap, TName extends keyof TResources & string>(ctx: DataContext<TResources>, resource: TName, options?: MutationModeOptions): {
45
49
  mutationFn: (params: DeleteParams<RecordOf<TResources, TName>>) => Promise<RecordOf<TResources, TName>>;
@@ -1,8 +1,22 @@
1
- import { removeManyEntries, patchManyEntries } from "./cache-ops.js";
1
+ import { removeManyEntries, patchManyEntries, patchListEntries } from "./cache-ops.js";
2
2
  import { oneKey, resourceKey, manyKeyPrefix } from "./keys.js";
3
3
  function invalidateResource(ctx, resource) {
4
4
  return ctx.queryClient.invalidateQueries({ queryKey: resourceKey(resource) });
5
5
  }
6
+ const unsettledUpdates = /* @__PURE__ */ new WeakMap();
7
+ function countUpdate(queryClient, resource, by) {
8
+ const counts = unsettledUpdates.get(queryClient) ?? /* @__PURE__ */ new Map();
9
+ unsettledUpdates.set(queryClient, counts);
10
+ const next = (counts.get(resource) ?? 0) + by;
11
+ counts.set(resource, next);
12
+ return next;
13
+ }
14
+ function cancelListQueries(ctx, resource) {
15
+ return ctx.queryClient.cancelQueries({
16
+ queryKey: resourceKey(resource),
17
+ predicate: ({ queryKey }) => queryKey[1] === "getList" || queryKey[1] === "getManyReference" || queryKey[1] === "getInfiniteList"
18
+ });
19
+ }
6
20
  async function snapshotManyEntries(ctx, resource) {
7
21
  await ctx.queryClient.cancelQueries({ queryKey: manyKeyPrefix(resource) });
8
22
  return ctx.queryClient.getQueriesData({ queryKey: manyKeyPrefix(resource) });
@@ -23,13 +37,16 @@ function createMutationOptions(ctx, resource) {
23
37
  }
24
38
  function updateMutationOptions(ctx, resource, options = {}) {
25
39
  const mode = options.mutationMode ?? "pessimistic";
40
+ const mutationKey = [resource, "update"];
26
41
  return {
42
+ mutationKey,
27
43
  mutationFn: async (params) => {
28
44
  const result = await ctx.dataProvider.update(resource, params);
29
45
  return result.data;
30
46
  },
31
47
  onMutate: async (params) => {
32
48
  if (mode !== "optimistic") return {};
49
+ countUpdate(ctx.queryClient, resource, 1);
33
50
  const key = oneKey(resource, { id: params.id });
34
51
  await ctx.queryClient.cancelQueries({ queryKey: key });
35
52
  const previous = ctx.queryClient.getQueryData(key);
@@ -47,15 +64,28 @@ function updateMutationOptions(ctx, resource, options = {}) {
47
64
  /* @__PURE__ */ new Set([String(params.id)]),
48
65
  structuredClone(params.data)
49
66
  );
50
- return { previous, key, manySnapshot };
67
+ await cancelListQueries(ctx, resource);
68
+ const rollbackLists = patchListEntries(
69
+ ctx.queryClient,
70
+ resource,
71
+ /* @__PURE__ */ new Set([String(params.id)]),
72
+ structuredClone(params.data)
73
+ );
74
+ return { previous, key, manySnapshot, rollbackLists };
51
75
  },
52
76
  onError: (_error, _params, context) => {
53
77
  if (context?.key && context.previous !== void 0) {
54
78
  ctx.queryClient.setQueryData(context.key, context.previous);
55
79
  }
56
80
  restoreManyEntries(ctx, context?.manySnapshot);
81
+ context?.rollbackLists?.();
57
82
  },
58
- onSettled: () => invalidateResource(ctx, resource)
83
+ onSettled: () => {
84
+ if (mode === "optimistic" && countUpdate(ctx.queryClient, resource, -1) > 0) {
85
+ return;
86
+ }
87
+ return invalidateResource(ctx, resource);
88
+ }
59
89
  };
60
90
  }
61
91
  function deleteMutationOptions(ctx, resource, options = {}) {
@@ -1,3 +1,4 @@
1
+ import type { LooseRecord } from "../resource/record.js";
1
2
  import type { CheloneFormApi } from "./form-controller.js";
2
3
  import { type FieldBinding, type FieldBindingOptions } from "./input-binding.js";
3
4
  /**
@@ -27,7 +28,7 @@ export interface ArrayRow {
27
28
  /** Moves THIS row to `to` — {@link ArrayBinding.reOrder} from the row's index. */
28
29
  reOrder(to: number): void;
29
30
  }
30
- export interface ArrayBindingOptions extends Omit<FieldBindingOptions, "format" | "parse"> {
31
+ export interface ArrayBindingOptions<TRecord = LooseRecord> extends Omit<FieldBindingOptions<TRecord>, "format" | "parse"> {
31
32
  /**
32
33
  * Seed rows for a value hole (ra precedence: an existing record value,
33
34
  * including `[]`, always wins). Defaults to `[]` so positional ops always
@@ -51,7 +52,7 @@ export interface ArrayBindingOptions extends Omit<FieldBindingOptions, "format"
51
52
  itemDefaults?: Record<string, unknown>;
52
53
  }
53
54
  /** `update()` payload: everything but the identity-defining source. */
54
- export type ArrayBindingUpdateOptions = Omit<ArrayBindingOptions, "source">;
55
+ export type ArrayBindingUpdateOptions = Omit<ArrayBindingOptions<LooseRecord>, "source">;
55
56
  /**
56
57
  * Vanilla array binding over a form-core array field — the useArrayInput
57
58
  * substrate (T8 wraps it in React contexts).
@@ -128,4 +129,4 @@ export interface ArrayBinding<TRecord> {
128
129
  */
129
130
  destroy(): void;
130
131
  }
131
- export declare function createArrayBinding<TRecord>(form: CheloneFormApi<TRecord>, options: ArrayBindingOptions): ArrayBinding<TRecord>;
132
+ export declare function createArrayBinding<TRecord>(form: CheloneFormApi<TRecord>, options: ArrayBindingOptions<TRecord>): ArrayBinding<TRecord>;
@@ -1,4 +1,6 @@
1
1
  import { type DeepKeys, type DeepValue, FieldApi, type FieldAsyncValidateOrFn, type FieldValidateOrFn, type StandardSchemaV1 } from "@tanstack/form-core";
2
+ import type { LooseRecord } from "../resource/record.js";
3
+ import type { SourcePath } from "../resource/source-path.js";
2
4
  import type { AsyncValidator, CheloneFormApi, SyncValidator } from "./form-controller.js";
3
5
  import { type Validator } from "./validators.js";
4
6
  /**
@@ -55,9 +57,9 @@ export interface FieldValidatorMeta {
55
57
  source: string;
56
58
  [key: string]: unknown;
57
59
  }
58
- export interface FieldBindingOptions {
60
+ export interface FieldBindingOptions<TRecord = LooseRecord> {
59
61
  /** The FINAL field path (already source-scoped — see `source.ts`). */
60
- source: string;
62
+ source: SourcePath<TRecord>;
61
63
  /**
62
64
  * Input-level default, ra precedence: seeds ONLY into a hole (untouched
63
65
  * field whose form value is `undefined`) — an existing record value,
@@ -81,7 +83,7 @@ export interface FieldBindingOptions {
81
83
  validatorMeta?: Record<string, unknown>;
82
84
  }
83
85
  /** `update()` payload: everything but the identity-defining source. */
84
- export type FieldBindingUpdateOptions = Omit<FieldBindingOptions, "source">;
86
+ export type FieldBindingUpdateOptions = Omit<FieldBindingOptions<LooseRecord>, "source">;
85
87
  /**
86
88
  * Vanilla field binding over a single `@tanstack/form-core` FieldApi —
87
89
  * the useInput substrate (T4 wraps it in React contexts).
@@ -137,5 +139,5 @@ export interface FieldBinding<TRecord> {
137
139
  */
138
140
  destroy(): void;
139
141
  }
140
- export declare function createFieldBinding<TRecord>(form: CheloneFormApi<TRecord>, options: FieldBindingOptions): FieldBinding<TRecord>;
142
+ export declare function createFieldBinding<TRecord>(form: CheloneFormApi<TRecord>, options: FieldBindingOptions<TRecord>): FieldBinding<TRecord>;
141
143
  export {};
@@ -5,9 +5,10 @@
5
5
  * identical path/label composition.
6
6
  *
7
7
  * Paths follow form-core's OWN syntax: array indices use brackets
8
- * (`items[0].sku`), object members use dots. Labels are translation-key
9
- * fragments and NEVER contain indices (`items.sku`), so one catalog entry
10
- * covers every row.
8
+ * (`items[0].sku`), object members use dots. A source written the other way
9
+ * (`items.0.sku`) is normalized to it — see {@link normalizeSource}. Labels
10
+ * are translation-key fragments and NEVER contain indices (`items.sku`), so
11
+ * one catalog entry covers every row.
11
12
  */
12
13
  export interface SourceScope {
13
14
  /** The final field path for a child source (`''` = the scope itself). */
@@ -20,7 +21,24 @@ export interface SourceScope {
20
21
  * directly (form-core index syntax), everything else joins with a dot.
21
22
  */
22
23
  export declare function composeSource(parent: string, child: string): string;
23
- /** The identity scope — what a top-level form provides. */
24
+ /**
25
+ * Rewrite a dot-spelled array index as form-core's bracket spelling:
26
+ * `tags.0.label` -> `tags[0].label`. Without it an input written with a dot
27
+ * index binds a field named `tags.0.label`, which no validator, error or
28
+ * field group matches, while the same path read as data (a `DataTable.Col`)
29
+ * works — form-core's `getBy`/`setBy` accept both spellings, so only the
30
+ * FIELD NAME differs.
31
+ *
32
+ * Only a digits-only member that FOLLOWS another member is an index: a
33
+ * leading numeral (`0.a`) is a field name, and bracket spellings, non-numeric
34
+ * members and the empty string pass through untouched. Labels never go
35
+ * through this — translation keys keep the spelling as written.
36
+ */
37
+ export declare function normalizeSource(source: string): string;
38
+ /**
39
+ * What a top-level form provides: the identity scope for everything but a
40
+ * dot-spelled index, which it rewrites to form-core's bracket spelling.
41
+ */
24
42
  export declare const rootSourceScope: SourceScope;
25
43
  /**
26
44
  * The scope an array input provides to its iterator: sources nest under the
@@ -3,14 +3,43 @@ function composeSource(parent, child) {
3
3
  if (!parent) return child;
4
4
  return child.startsWith("[") ? `${parent}${child}` : `${parent}.${child}`;
5
5
  }
6
+ function normalizeSource(source) {
7
+ let out = "";
8
+ let depth = 0;
9
+ let afterMember = false;
10
+ for (let i = 0; i < source.length; i++) {
11
+ const char = source[i];
12
+ if (char === "[") depth++;
13
+ if (char === "]") depth--;
14
+ if (char === "." && depth === 0 && afterMember) {
15
+ let end = i + 1;
16
+ while (end < source.length && source[end] !== "." && source[end] !== "[") {
17
+ end++;
18
+ }
19
+ const member = source.slice(i + 1, end);
20
+ if (/^\d+$/.test(member)) {
21
+ out += `[${member}]`;
22
+ i = end - 1;
23
+ continue;
24
+ }
25
+ }
26
+ out += char;
27
+ if (char !== ".") afterMember = true;
28
+ }
29
+ return out;
30
+ }
6
31
  const rootSourceScope = {
7
- getSource: (source) => source,
32
+ getSource: (source) => normalizeSource(source),
8
33
  getLabel: (source) => source
9
34
  };
10
35
  function createArraySourceScope(parent, arraySource) {
11
36
  const arrayPath = parent.getSource(arraySource);
12
37
  return {
13
- getSource: (source) => composeSource(arrayPath, source),
38
+ // Normalizing the JOIN, not just the child: a child of `0` composes to
39
+ // `tags.0`, where the index only follows a member once the array is there.
40
+ // Row and locale scopes need neither — they hand the whole path to a
41
+ // parent that normalizes it.
42
+ getSource: (source) => normalizeSource(composeSource(arrayPath, source)),
14
43
  getLabel: (source) => parent.getLabel(composeSource(arraySource, source))
15
44
  };
16
45
  }
@@ -42,5 +71,6 @@ export {
42
71
  createArraySourceScope,
43
72
  createLocaleSourceScope,
44
73
  createRowSourceScope,
74
+ normalizeSource,
45
75
  rootSourceScope
46
76
  };