@chelone/core 0.2.0 → 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.
@@ -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 {};
@@ -3,9 +3,9 @@
3
3
  * bracket indices (`a[0].b`) and numeric-dot indices (`a.0.b`) all resolve to
4
4
  * the same value, matching `lodash/get`'s relevant semantics.
5
5
  *
6
- * The core tree, calendar and client-list readers read through this, so a
7
- * `source` written one way reads the same record everywhere. The adapters'
8
- * `useFieldValue` reads the same grammar and moves onto this helper next.
6
+ * The core tree, calendar and client-list readers read through this, and so do
7
+ * both adapters' `useFieldValue` and `useGetRecordRepresentation`: a `source`
8
+ * written one way reads the same record everywhere, and one walker is left.
9
9
  */
10
10
  /**
11
11
  * Read the value at `source` off `record`. An empty `source` returns the record
@@ -1,20 +1,13 @@
1
1
  const PATH_SEGMENT = /\[([^\]]*)\]|[^.[\]]+/g;
2
- const toPathSegments = (source) => {
3
- const segments = [];
4
- for (const match of source.matchAll(PATH_SEGMENT)) {
5
- segments.push(match[1] !== void 0 ? match[1] : match[0]);
6
- }
7
- return segments;
8
- };
9
2
  function getAtPath(record, source) {
10
3
  if (source === "") return record;
11
4
  if (record !== null && typeof record === "object" && Object.hasOwn(record, source)) {
12
5
  return record[source];
13
6
  }
14
7
  let current = record;
15
- for (const key of toPathSegments(source)) {
8
+ for (const match of source.matchAll(PATH_SEGMENT)) {
16
9
  if (current === null || typeof current !== "object") return void 0;
17
- current = current[key];
10
+ current = current[match[1] !== void 0 ? match[1] : match[0]];
18
11
  }
19
12
  return current;
20
13
  }
@@ -1,5 +1,6 @@
1
1
  import type { StandardSchemaV1 } from "@tanstack/form-core";
2
2
  import type { LooseRecord } from "./record.js";
3
+ import type { SourceKey } from "./source-path.js";
3
4
  /** Per-resource configuration: an optional Standard Schema (Zod 3, Zod 4, Valibot, ...) and the references its fields hold. */
4
5
  export interface ResourceConfig<TSchema extends StandardSchemaV1 = StandardSchemaV1> {
5
6
  schema?: TSchema;
@@ -20,7 +21,20 @@ export type ResourceMap = {
20
21
  * `defineResources({ posts: { schema: postSchema } })` keeps `"posts"` literal
21
22
  * and `typeof postSchema` exact, enabling end-to-end inference.
22
23
  */
23
- export declare function defineResources<const T extends ResourceMap>(resources: T): T;
24
+ export declare function defineResources<const T extends ResourceMap>(resources: T & Checked<T>): T;
25
+ /**
26
+ * Narrows each resource's `references` to keys that are top-level fields of its
27
+ * record and values that are registered resource names. A mapped type over the
28
+ * keys actually written is what makes a misspelled key an error: an optional
29
+ * `{ [K in SourceKey]?: ... }` intersection lets inference widen `T` instead.
30
+ */
31
+ type Checked<T extends ResourceMap> = {
32
+ [N in keyof T & string]: {
33
+ references?: {
34
+ [K in keyof NonNullable<T[N]["references"]>]: K extends SourceKey<RecordOf<T, N>> ? keyof T & string : never;
35
+ };
36
+ };
37
+ };
24
38
  /**
25
39
  * The record type for a given resource name in a registry:
26
40
  * the schema's Standard Schema output type when a schema is present, else `LooseRecord`.
@@ -28,3 +42,4 @@ export declare function defineResources<const T extends ResourceMap>(resources:
28
42
  export type RecordOf<TResources extends ResourceMap, TName extends keyof TResources> = TResources[TName] extends {
29
43
  schema: infer S;
30
44
  } ? S extends StandardSchemaV1<unknown, infer Output> ? Output : LooseRecord : LooseRecord;
45
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chelone/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",