@chelone/core 0.10.0 → 0.11.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 (42) hide show
  1. package/dist/block-editor/block-document.d.ts +1 -1
  2. package/dist/controller/calendar-events.d.ts +6 -4
  3. package/dist/controller/calendar-events.js +2 -3
  4. package/dist/controller/client-list-controller.d.ts +3 -3
  5. package/dist/controller/client-list-controller.js +4 -20
  6. package/dist/controller/filter-toggle.d.ts +4 -4
  7. package/dist/controller/filter-toggle.js +0 -7
  8. package/dist/controller/in-place-editor.d.ts +1 -1
  9. package/dist/controller/infinite-list-controller.d.ts +1 -1
  10. package/dist/controller/kanban-board.d.ts +5 -3
  11. package/dist/controller/list-controller.d.ts +105 -28
  12. package/dist/controller/list-controller.js +85 -43
  13. package/dist/controller/list-params.d.ts +16 -13
  14. package/dist/controller/record-diff.d.ts +4 -3
  15. package/dist/controller/record-diff.js +2 -1
  16. package/dist/controller/reference-many-controller.d.ts +9 -7
  17. package/dist/controller/reference-one-controller.d.ts +8 -6
  18. package/dist/controller/saved-queries.d.ts +15 -14
  19. package/dist/controller/saved-queries.js +5 -0
  20. package/dist/csv/import-controller.d.ts +2 -1
  21. package/dist/csv/types.d.ts +12 -5
  22. package/dist/data/cache-ops.d.ts +8 -5
  23. package/dist/data/remove-empty.d.ts +2 -1
  24. package/dist/data/remove-empty.js +3 -5
  25. package/dist/data/types.d.ts +109 -6
  26. package/dist/data/undoable.d.ts +3 -2
  27. package/dist/form/choices-controller.d.ts +19 -12
  28. package/dist/form/choices-controller.js +19 -7
  29. package/dist/form/filter-form-values.d.ts +4 -3
  30. package/dist/form/filter-form-values.js +7 -5
  31. package/dist/i18n/direction.d.ts +18 -0
  32. package/dist/i18n/direction.js +72 -0
  33. package/dist/i18n/english-messages.d.ts +145 -150
  34. package/dist/i18n/english-messages.js +145 -150
  35. package/dist/index.d.ts +6 -4
  36. package/dist/index.js +3 -2
  37. package/dist/realtime/live.d.ts +5 -5
  38. package/dist/realtime/types.d.ts +6 -6
  39. package/dist/resource/describe-resource.d.ts +9 -3
  40. package/dist/resource/registry-store.d.ts +5 -2
  41. package/dist/resource/source-path.d.ts +23 -0
  42. package/package.json +1 -1
@@ -2,10 +2,11 @@
2
2
  * Pure rules for a record diff. No react, no vue: both DiffViewer adapters
3
3
  * mark the same field the same way and print the same default text.
4
4
  */
5
+ import type { LooseRecord } from "../resource/record.js";
5
6
  import type { SourceProp } from "../resource/source-path.js";
6
7
  export type DiffStatus = "unchanged" | "added" | "removed" | "changed";
7
- export interface RecordDiffRow {
8
- source: string;
8
+ export interface RecordDiffRow<R = LooseRecord> {
9
+ source: SourceProp<R>;
9
10
  status: DiffStatus;
10
11
  before: unknown;
11
12
  after: unknown;
@@ -16,6 +17,6 @@ export interface RecordDiffRow {
16
17
  * `null` and `undefined` both count as missing; an empty string is a value.
17
18
  * Neither record is changed.
18
19
  */
19
- export declare function diffRecords<R>(before: R, after: R, sources?: readonly SourceProp<R>[]): RecordDiffRow[];
20
+ export declare function diffRecords<R>(before: R, after: R, sources?: readonly SourceProp<R>[]): RecordDiffRow<R>[];
20
21
  /** The text a viewer shows for a value when the app gives no renderer. */
21
22
  export declare function formatDiffValue(value: unknown): string;
@@ -25,7 +25,8 @@ function statusOf(before, after) {
25
25
  */
26
26
  function diffRecords(before, after, sources) {
27
27
  const beforeKeys = topLevelKeys(before);
28
- return (sources ?? [...beforeKeys, ...topLevelKeys(after).filter((key) => !beforeKeys.includes(key))]).map((source) => {
28
+ const inferredKeys = [...beforeKeys, ...topLevelKeys(after).filter((key) => !beforeKeys.includes(key))];
29
+ return (sources ?? inferredKeys).map((source) => {
29
30
  const was = getAtPath(before, source);
30
31
  const now = getAtPath(after, source);
31
32
  return {
@@ -1,18 +1,20 @@
1
1
  import type { FilterPayload, SortPayload } from "../data/types.js";
2
2
  import type { Identifier } from "../resource/identifier.js";
3
+ import type { LooseRecord } from "../resource/record.js";
3
4
  import type { RecordOf, ResourceMap } from "../resource/registry.js";
5
+ import type { SourceKey } from "../resource/source-path.js";
4
6
  import { type ListController, type ListControllerContext } from "./list-controller.js";
5
- export interface ReferenceManyControllerOptions {
6
- /** The field on the referenced-FROM resource that holds the foreign key. */
7
- target: string;
7
+ export interface ReferenceManyControllerOptions<TRecord = LooseRecord> {
8
+ /** The field on the referenced-FROM resource that holds the foreign key. A key of the referenced record. */
9
+ target: SourceKey<TRecord>;
8
10
  /** The value `target` must equal. Null or undefined fetches nothing. */
9
11
  id: Identifier | null | undefined;
10
12
  /** Permanent filter, merged OVER the user filter for every fetch. */
11
- filter?: FilterPayload;
13
+ filter?: FilterPayload<TRecord>;
12
14
  /** Seed for the user filter when nothing is persisted. */
13
- filterDefaultValues?: FilterPayload;
15
+ filterDefaultValues?: FilterPayload<TRecord>;
14
16
  /** Initial sort when nothing is persisted: one payload or several, applied in order. Default id ascending. */
15
- sort?: SortPayload | SortPayload[];
17
+ sort?: SortPayload<TRecord> | SortPayload<TRecord>[];
16
18
  /** Default 25. */
17
19
  perPage?: number;
18
20
  /** Debounce delay for `setFilters(..., debounced=true)`. Default 500ms. */
@@ -42,4 +44,4 @@ export declare const DEFAULT_REFERENCE_PER_PAGE = 25;
42
44
  * params change, never from the result subscription; out-of-range page
43
45
  * resets go through the inner `setPage` and only on SETTLED results.
44
46
  */
45
- export declare function createReferenceManyController<TResources extends ResourceMap, TName extends keyof TResources & string>(ctx: ListControllerContext<TResources>, reference: TName, options: ReferenceManyControllerOptions): ReferenceManyController<RecordOf<TResources, TName>>;
47
+ export declare function createReferenceManyController<TResources extends ResourceMap, TName extends keyof TResources & string>(ctx: ListControllerContext<TResources>, reference: TName, options: ReferenceManyControllerOptions<RecordOf<TResources, TName>>): ReferenceManyController<RecordOf<TResources, TName>>;
@@ -1,17 +1,19 @@
1
1
  import type { FilterPayload, SortPayload } from "../data/types.js";
2
2
  import type { Identifier } from "../resource/identifier.js";
3
+ import type { LooseRecord } from "../resource/record.js";
3
4
  import type { RecordOf, ResourceMap } from "../resource/registry.js";
5
+ import type { SourceKey } from "../resource/source-path.js";
4
6
  import { type MicroStore } from "../store/micro-store.js";
5
7
  import type { ListControllerContext } from "./list-controller.js";
6
- export interface ReferenceOneControllerOptions {
7
- /** The field on the referenced-FROM resource that holds the foreign key. */
8
- target: string;
8
+ export interface ReferenceOneControllerOptions<TRecord = LooseRecord> {
9
+ /** The field on the referenced-FROM resource that holds the foreign key. A key of the referenced record. */
10
+ target: SourceKey<TRecord>;
9
11
  /** The value `target` must equal. Null or undefined fetches nothing. */
10
12
  id: Identifier | null | undefined;
11
13
  /** Decides which row is first when several match. Omitted: the provider's order. */
12
- sort?: SortPayload | SortPayload[];
14
+ sort?: SortPayload<TRecord> | SortPayload<TRecord>[];
13
15
  /** Narrows the lookup, e.g. to one kind of row. Default: no filter. */
14
- filter?: FilterPayload;
16
+ filter?: FilterPayload<TRecord>;
15
17
  /** Gates the fetch. A disabled lookup with no cached data reads `isPending: true`. Default true. */
16
18
  enabled?: boolean;
17
19
  }
@@ -36,4 +38,4 @@ export interface ReferenceOneController<TRecord> extends MicroStore<ReferenceOne
36
38
  * Lifecycle (Task 4R): construction is inert; the observer is acquired on the
37
39
  * first subscriber and released on the last unsubscribe.
38
40
  */
39
- export declare function createReferenceOneController<TResources extends ResourceMap, TName extends keyof TResources & string>(ctx: ListControllerContext<TResources>, reference: TName, options: ReferenceOneControllerOptions): ReferenceOneController<RecordOf<TResources, TName>>;
41
+ export declare function createReferenceOneController<TResources extends ResourceMap, TName extends keyof TResources & string>(ctx: ListControllerContext<TResources>, reference: TName, options: ReferenceOneControllerOptions<RecordOf<TResources, TName>>): ReferenceOneController<RecordOf<TResources, TName>>;
@@ -1,22 +1,23 @@
1
- import type { FilterPayload, SortPayload } from "../data/types.js";
1
+ import type { DisplayedFilters, FilterPayload, SortPayload } from "../data/types.js";
2
+ import type { LooseRecord } from "../resource/record.js";
2
3
  import { type MicroStore } from "../store/micro-store.js";
3
4
  import type { ListControllerContext } from "./list-controller.js";
4
- export interface SavedQueryValue {
5
- filter: FilterPayload;
6
- displayedFilters: Record<string, boolean>;
7
- sort: SortPayload[];
5
+ export interface SavedQueryValue<TRecord = LooseRecord> {
6
+ filter: FilterPayload<TRecord>;
7
+ displayedFilters: DisplayedFilters<TRecord>;
8
+ sort: SortPayload<TRecord>[];
8
9
  perPage: number;
9
10
  }
10
- export interface SavedQuery {
11
+ export interface SavedQuery<TRecord = LooseRecord> {
11
12
  label: string;
12
- value: SavedQueryValue;
13
+ value: SavedQueryValue<TRecord>;
13
14
  }
14
- export interface SavedQueriesState {
15
- queries: SavedQuery[];
15
+ export interface SavedQueriesState<TRecord = LooseRecord> {
16
+ queries: SavedQuery<TRecord>[];
16
17
  }
17
- export interface SavedQueriesController extends MicroStore<SavedQueriesState> {
18
+ export interface SavedQueriesController<TRecord = LooseRecord> extends MicroStore<SavedQueriesState<TRecord>> {
18
19
  /** Trims the label, ignores a blank one, replaces a same-label query in place, else appends. */
19
- save(label: string, value: SavedQueryValue): void;
20
+ save(label: string, value: SavedQueryValue<TRecord>): void;
20
21
  remove(label: string): void;
21
22
  }
22
23
  export interface SavedQueriesControllerOptions {
@@ -29,9 +30,9 @@ export declare function savedQueriesKey(storeKey: string): string;
29
30
  * Reads whatever storage holds: non-arrays give `[]`, malformed entries are
30
31
  * dropped, labels are trimmed, and a repeated label keeps its first entry.
31
32
  */
32
- export declare function extractValidSavedQueries(raw: unknown): SavedQuery[];
33
+ export declare function extractValidSavedQueries<TRecord = LooseRecord>(raw: unknown): SavedQuery<TRecord>[];
33
34
  /** Whether `current` list params are the saved query. `displayedFilters` is ignored. */
34
- export declare function isSavedQueryActive(query: SavedQueryValue, current: SavedQueryValue): boolean;
35
+ export declare function isSavedQueryActive<TRecord = LooseRecord>(query: SavedQueryValue<TRecord>, current: SavedQueryValue<TRecord>): boolean;
35
36
  /**
36
37
  * Named filter/sort sets persisted in the KV store.
37
38
  *
@@ -40,4 +41,4 @@ export declare function isSavedQueryActive(query: SavedQueryValue, current: Save
40
41
  * a write still updates local state, and the next attach picks up whatever the
41
42
  * store holds.
42
43
  */
43
- export declare function createSavedQueriesController(ctx: Pick<ListControllerContext, "store">, resource: string, options?: SavedQueriesControllerOptions): SavedQueriesController;
44
+ export declare function createSavedQueriesController<TRecord = LooseRecord>(ctx: Pick<ListControllerContext, "store">, resource: string, options?: SavedQueriesControllerOptions): SavedQueriesController<TRecord>;
@@ -6,6 +6,11 @@ function savedQueriesKey(storeKey) {
6
6
  return `${storeKey}.savedQueries`;
7
7
  }
8
8
  var isSortPayload = (value) => isRecord(value) && typeof value.field === "string" && (value.order === "ASC" || value.order === "DESC");
9
+ /**
10
+ * Structural only: a field renamed since the query was saved leaves a stale
11
+ * key, and no guard can check keys without the record. The predicate names the
12
+ * record the caller asked for, so a guarded value is trusted to hold its keys.
13
+ */
9
14
  function isSavedQueryValue(value) {
10
15
  return isRecord(value) && isRecord(value.filter) && isRecord(value.displayedFilters) && Object.values(value.displayedFilters).every((flag) => typeof flag === "boolean") && Array.isArray(value.sort) && value.sort.every(isSortPayload) && typeof value.perPage === "number" && Number.isInteger(value.perPage) && value.perPage > 0;
11
16
  }
@@ -1,3 +1,4 @@
1
+ import type { LooseRecord } from "../resource/record.js";
1
2
  import { type MicroStore } from "../store/micro-store.js";
2
3
  import type { CsvImportOptions, CsvImportState, ParsedCsv } from "./types.js";
3
4
  /** How many rows either adapter's preview step lists. */
@@ -22,4 +23,4 @@ export interface CsvImportController {
22
23
  cancel(): void;
23
24
  reset(): void;
24
25
  }
25
- export declare function createCsvImportController(options: CsvImportOptions): CsvImportController;
26
+ export declare function createCsvImportController<TRecord = LooseRecord>(options: CsvImportOptions<TRecord>): CsvImportController;
@@ -1,5 +1,7 @@
1
1
  import type { StandardSchemaV1 } from "@tanstack/form-core";
2
2
  import type { DataProvider } from "../data/types.js";
3
+ import type { LooseRecord } from "../resource/record.js";
4
+ import type { SourceKey } from "../resource/source-path.js";
3
5
  /** One problem the parser found. `row` is the 1-based data row (the header row is not counted). */
4
6
  export interface CsvParseError {
5
7
  code: string;
@@ -81,16 +83,21 @@ export interface CsvImportState {
81
83
  report: CsvImportReport | null;
82
84
  error: Error | null;
83
85
  }
84
- export interface CsvImportOptions {
86
+ /**
87
+ * `TRecord` is the schema's output, the record the import creates, so it may
88
+ * lack `id` and is not bounded by `ResourceRecord`. Without a schema it is
89
+ * `LooseRecord` and every field name is a plain string.
90
+ */
91
+ export interface CsvImportOptions<TRecord = LooseRecord> {
85
92
  dataProvider: DataProvider;
86
93
  resource: string;
87
- schema?: StandardSchemaV1;
94
+ schema?: StandardSchemaV1<unknown, TRecord>;
88
95
  /** Overrides the schema's keys. Without either, the CSV headers are the fields. */
89
- fields?: string[];
96
+ fields?: SourceKey<TRecord>[];
90
97
  /** Overrides the schema's non-optional keys. */
91
- requiredFields?: string[];
98
+ requiredFields?: SourceKey<TRecord>[];
92
99
  /** A starting field to header mapping. When non-empty it replaces the auto-match. */
93
- mapping?: Record<string, string>;
100
+ mapping?: Partial<Record<SourceKey<TRecord>, string>>;
94
101
  transform?: CsvTransform;
95
102
  batchSize?: number;
96
103
  onComplete?: (report: CsvImportReport) => void;
@@ -1,18 +1,20 @@
1
1
  import type { QueryClient } from "@tanstack/query-core";
2
2
  import type { Identifier } from "../resource/identifier.js";
3
- import type { LooseRecord, ResourceRecord } from "../resource/record.js";
3
+ import type { RecordOf, ResourceMap } from "../resource/registry.js";
4
+ /** The fields a patch may set on a record of the named resource. */
5
+ type Patch<TResources extends ResourceMap, TName extends keyof TResources> = Partial<RecordOf<TResources, TName>>;
4
6
  /**
5
7
  * Optimistically remove records: drop each `getOne` entry and filter the id(s)
6
8
  * out of every cached `getList`, `getManyReference` and `getInfiniteList` page
7
9
  * (decrementing `total` when known) and `getMany` entry.
8
10
  */
9
- export declare function removeRecordsFromCache<TRecord extends ResourceRecord = LooseRecord>(queryClient: QueryClient, resource: string, ids: Identifier[]): void;
11
+ export declare function removeRecordsFromCache<TResources extends ResourceMap = ResourceMap, TName extends keyof TResources & string = keyof TResources & string>(queryClient: QueryClient, resource: TName, ids: Identifier[]): void;
10
12
  /**
11
13
  * Optimistically patch records: shallow-merge `data` into each matching
12
14
  * `getOne` entry and every matching row of every cached `getList`,
13
15
  * `getManyReference`, `getInfiniteList` page and `getMany` entry.
14
16
  */
15
- export declare function updateRecordsInCache<TRecord extends ResourceRecord = LooseRecord>(queryClient: QueryClient, resource: string, ids: Identifier[], data: Partial<NoInfer<TRecord>>): void;
17
+ export declare function updateRecordsInCache<TResources extends ResourceMap = ResourceMap, TName extends keyof TResources & string = keyof TResources & string>(queryClient: QueryClient, resource: TName, ids: Identifier[], data: Patch<TResources, TName>): void;
16
18
  /**
17
19
  * Shallow-merge `data` into the matching rows of every cached `getList`,
18
20
  * `getManyReference` and `getInfiniteList` page, and return a `rollback` that
@@ -20,15 +22,16 @@ export declare function updateRecordsInCache<TRecord extends ResourceRecord = Lo
20
22
  * when it is called, so another update's patch to a different row survives.
21
23
  * An entry that holds none of the ids keeps its cached object.
22
24
  */
23
- export declare function patchListEntries<TRecord extends ResourceRecord = LooseRecord>(queryClient: QueryClient, resource: string, idSet: Set<string>, data: Partial<NoInfer<TRecord>>): () => void;
25
+ export declare function patchListEntries<TResources extends ResourceMap = ResourceMap, TName extends keyof TResources & string = keyof TResources & string>(queryClient: QueryClient, resource: TName, idSet: Set<string>, data: Patch<TResources, TName>): () => void;
24
26
  /**
25
27
  * Shallow-merge `data` into the matching records of every cached `getMany`
26
28
  * entry (what `ReferenceField` and `ReferenceArrayField` read through).
27
29
  */
28
- export declare function patchManyEntries<TRecord extends ResourceRecord = LooseRecord>(queryClient: QueryClient, resource: string, idSet: Set<string>, data: Partial<NoInfer<TRecord>>): void;
30
+ export declare function patchManyEntries<TResources extends ResourceMap = ResourceMap, TName extends keyof TResources & string = keyof TResources & string>(queryClient: QueryClient, resource: TName, idSet: Set<string>, data: Patch<TResources, TName>): void;
29
31
  /**
30
32
  * Drop the matching records from every cached `getMany` entry (what
31
33
  * `ReferenceField` and `ReferenceArrayField` read through). An entry that
32
34
  * holds none of the ids keeps its cached object.
33
35
  */
34
36
  export declare function removeManyEntries(queryClient: QueryClient, resource: string, idSet: Set<string>): void;
37
+ export {};
@@ -1,3 +1,4 @@
1
+ import type { LooseRecord } from "../resource/record.js";
1
2
  import type { FilterPayload } from "./types.js";
2
3
  /** Strips `''`, `null` and `undefined` values. Always returns a FRESH object. */
3
- export declare function removeEmpty(filter: FilterPayload): FilterPayload;
4
+ export declare function removeEmpty<TRecord = LooseRecord>(filter: FilterPayload<TRecord>): FilterPayload<TRecord>;
@@ -1,11 +1,9 @@
1
1
  // src/data/remove-empty.ts
2
2
  /** Strips `''`, `null` and `undefined` values. Always returns a FRESH object. */
3
3
  function removeEmpty(filter) {
4
- const result = {};
5
- for (const [key, value] of Object.entries(filter)) {
6
- if (value === "" || value === null || value === void 0) continue;
7
- result[key] = value;
8
- }
4
+ const result = { ...filter };
5
+ const pruned = result;
6
+ for (const [key, value] of Object.entries(pruned)) if (value === "" || value === null || value === void 0) delete pruned[key];
9
7
  return result;
10
8
  }
11
9
  export { removeEmpty };
@@ -1,6 +1,7 @@
1
1
  import type { Identifier } from "../resource/identifier.js";
2
2
  import type { LooseRecord, ResourceRecord } from "../resource/record.js";
3
3
  import type { RecordOf, ResourceMap } from "../resource/registry.js";
4
+ import type { AnyFilterSourceKey, AnySourcePath, RecordFilterKey, SourcePath } from "../resource/source-path.js";
4
5
  export interface PagePagination {
5
6
  page: number;
6
7
  perPage: number;
@@ -17,17 +18,118 @@ export interface PageInfo {
17
18
  hasNextPage?: boolean;
18
19
  hasPreviousPage?: boolean;
19
20
  }
20
- export interface SortPayload {
21
+ /**
22
+ * The sort a list applies: one field of the record and a direction.
23
+ *
24
+ * `field` is a {@link SourcePath} of the record, so sorting on a column the
25
+ * record does not have stops compiling. A loose record (the default, and every
26
+ * app that registers nothing) takes {@link AnySourcePath} instead, the same
27
+ * loose arm a `source` prop has, so a `DataTable` column can write its own
28
+ * `SourceProp` into `setSort`.
29
+ *
30
+ * A data provider verb takes {@link LooseSortPayload}: a controller generic over
31
+ * `TResources` cannot prove its `SortPayload<RecordOf<TResources, TName>>`
32
+ * assigns to the registered union.
33
+ */
34
+ export interface SortPayload<TRecord = LooseRecord> {
35
+ field: _SortFieldOf<TRecord>;
36
+ order: "ASC" | "DESC";
37
+ }
38
+ /**
39
+ * The field a sort takes: the registered union of source paths for a loose
40
+ * record, the record's own paths for a typed one.
41
+ *
42
+ * The conditional sits on the field, never between two payload shapes: a union
43
+ * of a resolved arm and a deferred one is what stops two identical
44
+ * `SortPayload<TRecord>`s assigning to each other inside a generic function.
45
+ * `LooseRecord extends TRecord` is {@link SourceKey}'s guard, not the
46
+ * `unknown extends` the path types use: an open type parameter resolves against
47
+ * its constraint, and `SourcePath<ResourceRecord>` is `"id"`.
48
+ */
49
+ type _SortFieldOf<TRecord> = LooseRecord extends TRecord ? AnySourcePath : SourcePath<TRecord>;
50
+ /**
51
+ * A sort with no record behind it: an open field name. {@link SortPayload}'s
52
+ * loose field is the registered union once an app registers resources, so the
53
+ * verbs of {@link DataProvider}, and the code that reads what a verb receives
54
+ * (`applySort`, `buildOrder`), name this instead.
55
+ */
56
+ export interface LooseSortPayload {
21
57
  field: string;
22
58
  order: "ASC" | "DESC";
23
59
  }
24
- export type FilterPayload = {
60
+ /**
61
+ * A filter object: {@link RecordFilterKey}s mapped to values — a record's source
62
+ * paths, those paths with an operator suffix, and `q`. Keys come from the
63
+ * resource registry the way a `source` prop does from {@link SourceProp}, so an
64
+ * app that registers its resources gets `titel_gte` rejected at the call site.
65
+ *
66
+ * Values stay `unknown`: an operator suffix makes a key's value type ambiguous
67
+ * (`title_gte` wants a string, `views_gte` a number), and the provider decides
68
+ * what a value means.
69
+ *
70
+ * A loose record (the default, and every app that registers nothing) is keyed by
71
+ * {@link AnyFilterSourceKey}, which is plain `string` until `Register` is
72
+ * augmented.
73
+ */
74
+ export type FilterPayload<TRecord = LooseRecord> = {
75
+ [TKey in _FilterKeyOf<TRecord>]?: unknown;
76
+ };
77
+ /**
78
+ * The keys a filter takes: the registered union for a loose record, the
79
+ * record's own keys for a typed one.
80
+ *
81
+ * `FilterPayload` maps over that union as ONE key set rather than picking one
82
+ * of two object shapes with a conditional. A union of a resolved arm and a
83
+ * deferred one is what stops two identical `FilterPayload<TRecord>`s assigning
84
+ * to each other inside a generic function, and mapping over `string` is how an
85
+ * unregistered app keeps today's plain index-signature object.
86
+ */
87
+ type _FilterKeyOf<TRecord> = LooseRecord extends TRecord ? AnyFilterSourceKey : RecordFilterKey<TRecord>;
88
+ /**
89
+ * A filter with no record behind it: an open key map. {@link FilterPayload}'s
90
+ * loose key set is the registered union once an app registers resources, so
91
+ * code that walks a filter key by key — pruning one, stripping empties — names
92
+ * this instead.
93
+ */
94
+ export type LooseFilterPayload = {
25
95
  [key: string]: unknown;
26
96
  };
97
+ /**
98
+ * Which filter inputs the list shows: a filter key mapped to whether its input
99
+ * is open. `showFilter(name)` writes `{ [name]: true }` into the filter and
100
+ * `hideFilter(name)` removes that path from it, so the map is keyed by the same
101
+ * union {@link FilterPayload} is — a name that is no filter key of the record
102
+ * stops compiling.
103
+ *
104
+ * The values are optional: a map over a literal key union that demanded every
105
+ * key would force an app to list every filter it has in order to show one.
106
+ */
107
+ export type DisplayedFilters<TRecord = LooseRecord> = {
108
+ [TKey in _FilterKeyOf<TRecord>]?: boolean;
109
+ };
110
+ /**
111
+ * The displayed filters with no record behind it: an open key map.
112
+ * {@link DisplayedFilters}'s loose key set is the registered union once an app
113
+ * registers resources, so code that walks the map key by key — a filter form
114
+ * reading `displayedFilters[source]`, an adapter's list context — names this
115
+ * instead.
116
+ *
117
+ * The value carries `undefined` because {@link DisplayedFilters} does: a map
118
+ * over a literal key union with optional values has no `string` index
119
+ * signature of plain `boolean` to hand over, so an erased map that demanded one
120
+ * would reject every typed controller.
121
+ */
122
+ export type LooseDisplayedFilters = {
123
+ [key: string]: boolean | undefined;
124
+ };
27
125
  export interface GetListParams {
28
126
  pagination?: Pagination;
29
- /** Single sort key, or multi-sort: keys applied in order (first is primary). */
30
- sort?: SortPayload | SortPayload[];
127
+ /**
128
+ * Single sort key, or multi-sort: keys applied in order (first is primary).
129
+ * Loose, like {@link GetListParams.filter}: a verb is not generic over the
130
+ * record, and a controller's typed sort assigns to it with no cast.
131
+ */
132
+ sort?: LooseSortPayload | LooseSortPayload[];
31
133
  filter?: FilterPayload;
32
134
  meta?: unknown;
33
135
  }
@@ -88,8 +190,8 @@ export interface GetManyReferenceParams {
88
190
  target: string;
89
191
  id: Identifier;
90
192
  pagination?: Pagination;
91
- /** Single sort key, or multi-sort: keys applied in order (first is primary). */
92
- sort?: SortPayload | SortPayload[];
193
+ /** Single sort key, or multi-sort: keys applied in order (first is primary). Loose, as in {@link GetListParams}. */
194
+ sort?: LooseSortPayload | LooseSortPayload[];
93
195
  filter?: FilterPayload;
94
196
  meta?: unknown;
95
197
  }
@@ -125,3 +227,4 @@ export interface DataProvider<TResources extends ResourceMap = ResourceMap> {
125
227
  updateMany<TName extends keyof TResources & string>(resource: TName, params: UpdateManyParams<RecordOf<TResources, TName>>): Promise<UpdateManyResult>;
126
228
  deleteMany<TName extends keyof TResources & string>(resource: TName, params: DeleteManyParams): Promise<DeleteManyResult>;
127
229
  }
230
+ export {};
@@ -1,6 +1,7 @@
1
1
  import { type QueryClient, type QueryKey } from "@tanstack/query-core";
2
2
  import type { Identifier } from "../resource/identifier.js";
3
- import type { LooseRecord, ResourceRecord } from "../resource/record.js";
3
+ import type { ResourceRecord } from "../resource/record.js";
4
+ import type { RecordOf, ResourceMap } from "../resource/registry.js";
4
5
  import type { DeleteParams, UpdateManyParams, UpdateParams } from "./types.js";
5
6
  /**
6
7
  * A snapshot of the query cache taken BEFORE an optimistic apply: an array of
@@ -102,7 +103,7 @@ export interface UndoableCacheOptions {
102
103
  };
103
104
  }
104
105
  /** Patch `ids` in the `resource` caches now; the notification reads "updated". */
105
- export declare function undoableUpdateOptions<TRecord extends ResourceRecord = LooseRecord>(queryClient: QueryClient, resource: string, ids: Identifier[], data: Partial<NoInfer<TRecord>>): UndoableCacheOptions;
106
+ export declare function undoableUpdateOptions<TResources extends ResourceMap = ResourceMap, TName extends keyof TResources & string = keyof TResources & string>(queryClient: QueryClient, resource: TName, ids: Identifier[], data: Partial<RecordOf<TResources, TName>>): UndoableCacheOptions;
106
107
  /** Remove `ids` from the `resource` caches now; the notification reads "deleted". */
107
108
  export declare function undoableDeleteOptions(queryClient: QueryClient, resource: string, ids: Identifier[]): UndoableCacheOptions;
108
109
  /** The record an undoable update shows at once: the previous one with the patch on top. */
@@ -19,12 +19,12 @@ export type ChoiceRecord = Record<string, unknown>;
19
19
  * (deduped by `lookupKey`), so a currently-selected value that is not on the
20
20
  * page still renders as a labelled choice.
21
21
  */
22
- export interface ChoicesControllerState<TRecord> {
22
+ export interface ChoicesControllerState<TRecord, TName extends string = string> {
23
23
  page: number;
24
24
  perPage: number;
25
- sort: SortPayload;
25
+ sort: SortPayload<TRecord>;
26
26
  /** The dynamic (user) filter — permanent filter is merged in on fetch. */
27
- filter: FilterPayload;
27
+ filter: FilterPayload<TRecord>;
28
28
  /** All choices: page + missing selected prepended (deduped by lookupKey). */
29
29
  allChoices: TRecord[];
30
30
  /** The fetched page only. */
@@ -40,19 +40,19 @@ export interface ChoicesControllerState<TRecord> {
40
40
  isFetching: boolean;
41
41
  error: Error | null;
42
42
  /** The referenced resource name. */
43
- resource: string;
43
+ resource: TName;
44
44
  /** The FK field on the referencing side (metadata for consumers). */
45
45
  source: string | undefined;
46
46
  /** Always `true` — distinguishes a reference-backed context from an inline-choices one. */
47
47
  isFromReference: true;
48
48
  }
49
- export interface ChoicesController<TRecord> extends MicroStore<ChoicesControllerState<TRecord>> {
49
+ export interface ChoicesController<TRecord, TName extends string = string> extends MicroStore<ChoicesControllerState<TRecord, TName>> {
50
50
  setPage(page: number): void;
51
51
  setPerPage(perPage: number): void;
52
52
  /** Replaces the sort and resets the page to 1. */
53
- setSort(sort: SortPayload): void;
53
+ setSort(sort: SortPayload<TRecord>): void;
54
54
  /** Sets the dynamic filter (`removeEmpty` applied). `debounce` delays by `debounceMs`. */
55
- setFilters(filter: FilterPayload, debounce?: boolean): void;
55
+ setFilters(filter: FilterPayload<TRecord>, debounce?: boolean): void;
56
56
  /** Feeds the selected-record resolution. Scalar or array; `null`/`[]` clears. */
57
57
  setValue(value: unknown): void;
58
58
  refetch(): void;
@@ -71,12 +71,12 @@ export interface ChoicesControllerOptions<TResources extends ResourceMap = Resou
71
71
  */
72
72
  lookupKey?: SourceKey<RecordOf<TResources, TName>>;
73
73
  /** Permanent filter — merged OVER the dynamic filter for every choices fetch. */
74
- filter?: FilterPayload;
74
+ filter?: FilterPayload<RecordOf<TResources, TName>>;
75
75
  page?: number;
76
76
  perPage?: number;
77
- sort?: SortPayload;
77
+ sort?: SortPayload<RecordOf<TResources, TName>>;
78
78
  /** Gate the CHOICES query on the current filter values. Selected always resolves. */
79
- enableGetChoices?: (filterValues: FilterPayload) => boolean;
79
+ enableGetChoices?: (filterValues: FilterPayload<RecordOf<TResources, TName>>) => boolean;
80
80
  /** Debounce delay for `setFilters(..., true)`. Default 500ms. */
81
81
  debounceMs?: number;
82
82
  /** The FK source field on the referencing side (metadata only). */
@@ -99,7 +99,7 @@ export interface ChoicesControllerOptions<TResources extends ResourceMap = Resou
99
99
  * actions (`setPage`/`setSort`/`setFilters`/`setValue`), never from the
100
100
  * observer result subscription — the subscription only maps results into state.
101
101
  */
102
- export declare function createChoicesController<TResources extends ResourceMap, TName extends keyof TResources & string>(ctx: ChoicesControllerContext<TResources>, options: ChoicesControllerOptions<TResources, TName>): ChoicesController<RecordOf<TResources, TName>>;
102
+ export declare function createChoicesController<TResources extends ResourceMap, TName extends keyof TResources & string>(ctx: ChoicesControllerContext<TResources>, options: ChoicesControllerOptions<TResources, TName>): ChoicesController<RecordOf<TResources, TName>, TName>;
103
103
  /** The three choice views of a state, erased to `ChoiceRecord[]`. */
104
104
  export interface ChoiceLists {
105
105
  allChoices: ChoiceRecord[];
@@ -112,4 +112,11 @@ export interface ChoiceLists {
112
112
  * `ChoiceRecord`, so neither adapter writes the cast. The arrays are the
113
113
  * state's own, not copies.
114
114
  */
115
- export declare function choiceListsOf<TRecord>(state: ChoicesControllerState<TRecord>): ChoiceLists;
115
+ export declare function choiceListsOf<TRecord, TName extends string>(state: ChoicesControllerState<TRecord, TName>): ChoiceLists;
116
+ /**
117
+ * The controller's sort, filter and actions as an adapter's erased
118
+ * `ChoicesContextValue` carries them: over `ChoiceRecord`, not the referenced
119
+ * record. The lists go through {@link choiceListsOf}. It is the same object, so
120
+ * every action still reaches the controller.
121
+ */
122
+ export declare function eraseChoicesController<TRecord, TName extends string>(controller: ChoicesController<TRecord, TName>): ChoicesController<ChoiceRecord>;
@@ -6,10 +6,13 @@ import { createMicroStore } from "../store/micro-store.js";
6
6
  import { QueryObserver, keepPreviousData } from "@tanstack/query-core";
7
7
  // src/form/choices-controller.ts
8
8
  var DEFAULT_PAGE = 1;
9
- var DEFAULT_SORT = {
10
- field: "id",
11
- order: "DESC"
12
- };
9
+ /** The sort of a controller given none: `id`, which every record has. */
10
+ function defaultSort() {
11
+ return {
12
+ field: "id",
13
+ order: "DESC"
14
+ };
15
+ }
13
16
  /** perPage for the non-id selected getList — generous, real values sets are small. */
14
17
  var SELECTED_LIST_PER_PAGE = 1e3;
15
18
  /** Normalizes a value into a non-empty identifier list, or `[]` when there is nothing to resolve. */
@@ -36,12 +39,12 @@ function createChoicesController(ctx, options) {
36
39
  const reference = options.reference;
37
40
  const lookupKey = options.lookupKey ?? "id";
38
41
  const isIdLookup = lookupKey === "id";
39
- const permanentFilter = options.filter ?? {};
42
+ const permanentFilter = options.filter === void 0 ? {} : options.filter;
40
43
  const debounceMs = options.debounceMs ?? 500;
41
44
  const store = createMicroStore({
42
45
  page: options.page ?? DEFAULT_PAGE,
43
46
  perPage: options.perPage ?? 25,
44
- sort: { ...options.sort ?? DEFAULT_SORT },
47
+ sort: { ...options.sort ?? defaultSort() },
45
48
  filter: {},
46
49
  allChoices: [],
47
50
  availableChoices: [],
@@ -318,4 +321,13 @@ function choiceListsOf(state) {
318
321
  selectedChoices
319
322
  };
320
323
  }
321
- export { choiceListsOf, createChoicesController };
324
+ /**
325
+ * The controller's sort, filter and actions as an adapter's erased
326
+ * `ChoicesContextValue` carries them: over `ChoiceRecord`, not the referenced
327
+ * record. The lists go through {@link choiceListsOf}. It is the same object, so
328
+ * every action still reaches the controller.
329
+ */
330
+ function eraseChoicesController(controller) {
331
+ return controller;
332
+ }
333
+ export { choiceListsOf, createChoicesController, eraseChoicesController };
@@ -1,4 +1,5 @@
1
1
  import type { FilterPayload } from "../data/types.js";
2
+ import type { LooseRecord } from "../resource/record.js";
2
3
  /**
3
4
  * Rebuild form values from the incoming filter so EVERY current form key is
4
5
  * present with a defined value (an empty string when the filter lacks it).
@@ -8,7 +9,7 @@ import type { FilterPayload } from "../data/types.js";
8
9
  * reset must carry a value for every mounted input. This never produces
9
10
  * `undefined`/`null` for a present form key.
10
11
  */
11
- export declare function getFilterFormValues(formValues: Record<string, unknown>, filter: Record<string, unknown>): Record<string, unknown>;
12
+ export declare function getFilterFormValues<TRecord = LooseRecord>(formValues: Record<string, unknown>, filter: FilterPayload<TRecord>): Record<string, unknown>;
12
13
  /**
13
14
  * The form shape the external-reflect must cover: every key that is either a
14
15
  * current form value, a REGISTERED field (form-core tracks these in `fieldMeta`
@@ -16,10 +17,10 @@ export declare function getFilterFormValues(formValues: Record<string, unknown>,
16
17
  * carried through; registered-but-unset / filter-only keys seed to `undefined`
17
18
  * so {@link getFilterFormValues} resolves them from the filter (fallback `''`).
18
19
  */
19
- export declare function filterFormReflectShape(values: Record<string, unknown>, fieldMeta: Record<string, unknown>, filter: Record<string, unknown>): Record<string, unknown>;
20
+ export declare function filterFormReflectShape<TRecord = LooseRecord>(values: Record<string, unknown>, fieldMeta: Record<string, unknown>, filter: FilterPayload<TRecord>): Record<string, unknown>;
20
21
  /**
21
22
  * Merge form values OVER the existing filter, one level deep, with arrays
22
23
  * REPLACED (never element-merged) — matching ra's mergeObjNotArray. The result
23
24
  * is a fresh object (no mutation of the inputs).
24
25
  */
25
- export declare function mergeFilterValues(filter: Record<string, unknown>, values: Record<string, unknown>): FilterPayload;
26
+ export declare function mergeFilterValues<TRecord = LooseRecord>(filter: FilterPayload<TRecord>, values: FilterPayload<TRecord>): FilterPayload<TRecord>;
@@ -56,14 +56,16 @@ function filterFormReflectShape(values, fieldMeta, filter) {
56
56
  */
57
57
  function mergeFilterValues(filter, values) {
58
58
  const merged = { ...filter };
59
- for (const key of Object.keys(values)) {
60
- const next = values[key];
61
- const prev = merged[key];
62
- if (isFilterBranch(next) && isFilterBranch(prev)) merged[key] = {
59
+ const incoming = values;
60
+ const target = merged;
61
+ for (const key of Object.keys(incoming)) {
62
+ const next = incoming[key];
63
+ const prev = target[key];
64
+ if (isFilterBranch(next) && isFilterBranch(prev)) target[key] = {
63
65
  ...prev,
64
66
  ...next
65
67
  };
66
- else merged[key] = next;
68
+ else target[key] = next;
67
69
  }
68
70
  return merged;
69
71
  }