@coffer-org/sdk 3.2.0 → 3.4.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.
package/dist/derive.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface DeriveSpec {
7
7
  deps: string[];
8
8
  fn: DeriveFn;
9
9
  }
10
- export declare function derivedEntries(fields: LayoutEl[]): [string, DeriveSpec][];
11
- export declare function applyDerived(fields: LayoutEl[], record: Record<string, unknown>): Record<string, unknown>;
10
+ export declare function derivedEntries(fields: LayoutEl[]): [string, DeriveFn][];
11
+ export declare function legacyDeriveDeps(fields: LayoutEl[]): [string, string[]][];
12
+ export declare function applyDerived(fields: LayoutEl[], record: Record<string, unknown>): Promise<Record<string, unknown>>;
12
13
  export declare function derivableKeys(fields: LayoutEl[]): Set<string>;
package/dist/derive.js CHANGED
@@ -1,23 +1,35 @@
1
1
  import { isField, isGroup } from "./fields.js";
2
- export function derivedEntries(fields) {
2
+ function topLevelFields(fields) {
3
3
  const out = [];
4
4
  for (const it of fields) {
5
- if (isField(it)) {
6
- const spec = it.type.derive;
7
- if (spec)
8
- out.push([it.key, spec]);
9
- }
10
- else if (isGroup(it) && !it.key) {
11
- out.push(...derivedEntries(it.fields));
12
- }
5
+ if (isField(it))
6
+ out.push([it.key, it.type]);
7
+ else if (isGroup(it) && !it.key)
8
+ out.push(...topLevelFields(it.fields));
9
+ }
10
+ return out;
11
+ }
12
+ export function derivedEntries(fields) {
13
+ const out = [];
14
+ for (const [key, meta] of topLevelFields(fields)) {
15
+ const fn = meta.compute ?? meta.derive?.fn;
16
+ if (fn)
17
+ out.push([key, typeof fn === 'function' ? fn : () => fn]);
13
18
  }
14
19
  return out;
15
20
  }
16
- export function applyDerived(fields, record) {
21
+ export function legacyDeriveDeps(fields) {
22
+ const out = [];
23
+ for (const [key, meta] of topLevelFields(fields))
24
+ if (meta.derive)
25
+ out.push([key, meta.derive.deps]);
26
+ return out;
27
+ }
28
+ export async function applyDerived(fields, record) {
17
29
  const out = {};
18
- for (const [key, spec] of derivedEntries(fields)) {
30
+ for (const [key, fn] of derivedEntries(fields)) {
19
31
  try {
20
- out[key] = spec.fn({ record });
32
+ out[key] = await fn({ record });
21
33
  }
22
34
  catch (e) {
23
35
  console.warn(`[derive] '${key}' threw`, e);
package/dist/extend.js CHANGED
@@ -1,10 +1,11 @@
1
- import { layoutToClient, fieldEntries, buildShape } from "./shelf.js";
1
+ import { layoutToClient, fieldEntries, buildShape, checkFieldDeclaration } from "./shelf.js";
2
2
  import { z } from 'zod';
3
3
  export function defineExtend(e) {
4
4
  const keys = fieldEntries(e.fields).map(([k]) => k);
5
5
  const dup = keys.find((k, i) => keys.indexOf(k) !== i);
6
6
  if (dup)
7
7
  throw new Error(`[extend] ${e.id}: duplicate key '${dup}'`);
8
+ checkFieldDeclaration(e.fields, `[extend] ${e.id}`);
8
9
  return e;
9
10
  }
10
11
  export function extendMatches(e, library, shelf) {
@@ -50,6 +50,7 @@ export interface FieldConfig {
50
50
  export type NormalizedOpts = {
51
51
  key?: string;
52
52
  value?: unknown;
53
+ store?: boolean;
53
54
  label?: string;
54
55
  options?: string | unknown[];
55
56
  slots?: string[];
package/dist/fields.d.ts CHANGED
@@ -2,11 +2,13 @@ import { z } from 'zod';
2
2
  import { type UnitsSpec } from './units.ts';
3
3
  export { vmsg, reqErr, typeErr, reqTypeErr, jsonRefined, optionalize, jsonValue, decodeVmsg, validateField, } from './fields/validation.ts';
4
4
  import type { Condition } from './condition.ts';
5
+ import { type PartsSpec, type ResolvedPart } from './parts.ts';
6
+ export { partValueKey, partValueKeys, type PartSlot } from './parts.ts';
5
7
  export type { Rules, View, FieldConfig } from './fields/normalize.ts';
6
8
  export { normalizeOpts } from './fields/normalize.ts';
7
9
  import { type Rules, type View } from './fields/normalize.ts';
8
10
  import { presets } from './field-presets.ts';
9
- export type CorePrim = 'text' | 'number' | 'date' | 'time' | 'datetime' | 'checkbox' | 'select' | 'json' | 'check' | 'measured' | 'relation' | 'file' | 'per-weekday' | 'lookup';
11
+ export type CorePrim = 'text' | 'number' | 'date' | 'time' | 'datetime' | 'checkbox' | 'select' | 'json' | 'check' | 'measured' | 'relation' | 'file' | 'per-weekday';
10
12
  export type Prim = CorePrim | (string & {});
11
13
  export type ColumnType = 'text' | 'integer' | 'real' | 'boolean' | 'date' | 'time' | 'datetime';
12
14
  export type OptionIcon = {
@@ -45,6 +47,9 @@ export interface ComputeCtx {
45
47
  library: string;
46
48
  shelf: string;
47
49
  };
50
+ self?: Record<string, unknown>;
51
+ parent?: Record<string, unknown>;
52
+ ref?: (key: string) => Promise<Record<string, unknown> | null>;
48
53
  }
49
54
  export type ComputeFn = (ctx: ComputeCtx) => unknown;
50
55
  export interface DividerEl {
@@ -104,6 +109,12 @@ export interface FieldClient {
104
109
  json?: true;
105
110
  derived?: true;
106
111
  columns?: Record<string, ColumnType>;
112
+ parts?: {
113
+ role: string;
114
+ key: string | null;
115
+ mode: string;
116
+ value?: unknown;
117
+ }[];
107
118
  hidden?: boolean | Condition;
108
119
  }
109
120
  export declare function group(o: {
@@ -175,6 +186,7 @@ export type FieldOptions = string | unknown[];
175
186
  export interface FieldCoreOpts {
176
187
  key?: string;
177
188
  value?: unknown;
189
+ store?: boolean;
178
190
  label?: string;
179
191
  rules?: Rules;
180
192
  view?: View;
@@ -195,12 +207,15 @@ export declare function wrapKey(opts: {
195
207
  emphasis?: 'hero' | 'muted';
196
208
  noLabel?: boolean;
197
209
  role?: 'cover' | 'avatar';
210
+ store?: boolean;
198
211
  derive?: import('./derive.ts').DeriveSpec;
199
212
  }, meta: FieldMeta): FieldItem | StaticEl | FieldMeta;
200
213
  export interface FieldMeta extends FieldClient {
201
214
  column: ColumnType;
202
215
  zod: z.ZodTypeAny;
203
216
  derive?: import('./derive.ts').DeriveSpec;
217
+ compute?: unknown;
218
+ parts?: ResolvedPart[];
204
219
  }
205
220
  export declare function isJsonStored(field: FieldMeta | FieldClient): boolean;
206
221
  export { LANGUAGES, SEX_OPTIONS, WEEKDAY_OPTIONS } from './fields/constants.ts';
@@ -423,6 +438,7 @@ export declare function check(o: CheckOpts & {
423
438
  }): FieldMeta;
424
439
  export interface MeasuredOpts extends FieldCoreOpts {
425
440
  options: FieldOptions;
441
+ parts?: PartsSpec;
426
442
  }
427
443
  export declare function measured(o: MeasuredOpts & {
428
444
  key: string;
@@ -456,7 +472,9 @@ export declare function amount(o: AmountOpts & {
456
472
  key?: undefined;
457
473
  value?: undefined;
458
474
  }): FieldMeta;
459
- export type MoneyOpts = FieldCoreOpts;
475
+ export interface MoneyOpts extends FieldCoreOpts {
476
+ parts?: PartsSpec;
477
+ }
460
478
  export declare function money(o: MoneyOpts & {
461
479
  key: string;
462
480
  }): FieldItem;
@@ -468,7 +486,9 @@ export declare function money(o: MoneyOpts & {
468
486
  key?: undefined;
469
487
  value?: undefined;
470
488
  }): FieldMeta;
471
- export type CodeFieldOpts = FieldCoreOpts;
489
+ export interface CodeFieldOpts extends FieldCoreOpts {
490
+ parts?: PartsSpec;
491
+ }
472
492
  export declare function code(o: CodeFieldOpts & {
473
493
  key: string;
474
494
  }): FieldItem;
@@ -480,7 +500,9 @@ export declare function code(o: CodeFieldOpts & {
480
500
  key?: undefined;
481
501
  value?: undefined;
482
502
  }): FieldMeta;
483
- export type GeoOpts = FieldCoreOpts;
503
+ export interface GeoOpts extends FieldCoreOpts {
504
+ parts?: PartsSpec;
505
+ }
484
506
  export declare function geo(o: GeoOpts & {
485
507
  key: string;
486
508
  }): FieldItem;
@@ -504,23 +526,10 @@ export declare function perWeekday(o: {
504
526
  export type RowSubField = FieldClient & {
505
527
  key: string;
506
528
  };
507
- export interface LookupOpts extends FieldCoreOpts {
508
- from: string;
509
- pick: string[];
510
- }
511
- export declare function lookup(o: LookupOpts & {
512
- key: string;
513
- }): FieldItem;
514
- export declare function lookup(o: LookupOpts & {
515
- value: unknown;
516
- key?: undefined;
517
- }): StaticEl;
518
- export declare function lookup(o: LookupOpts & {
519
- key?: undefined;
520
- value?: undefined;
521
- }): FieldMeta;
522
529
  export declare function clientToMeta(fc: FieldClient): FieldMeta;
523
- type PeriodOpts = FieldCoreOpts;
530
+ interface PeriodOpts extends FieldCoreOpts {
531
+ parts?: PartsSpec;
532
+ }
524
533
  export declare function period(o: PeriodOpts & {
525
534
  key: string;
526
535
  }): FieldItem | GroupEl;
@@ -642,7 +651,9 @@ export interface KeyValueOpts extends FieldCoreOpts {
642
651
  export declare function keyValue(raw: KeyValueOpts & {
643
652
  key: string;
644
653
  }): GroupEl;
645
- export type RangeOpts = FieldCoreOpts;
654
+ export interface RangeOpts extends FieldCoreOpts {
655
+ parts?: PartsSpec;
656
+ }
646
657
  export declare function numberRange(o: RangeOpts & {
647
658
  key: string;
648
659
  }): FieldItem;
@@ -701,7 +712,6 @@ declare const PRIMITIVES: {
701
712
  money: typeof money;
702
713
  code: typeof code;
703
714
  geo: typeof geo;
704
- lookup: typeof lookup;
705
715
  perWeekday: typeof perWeekday;
706
716
  period: typeof period;
707
717
  file: typeof file;
package/dist/fields.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { z } from 'zod';
2
2
  import { resolveUnits } from "./units.js";
3
- import { isCurrencyCode } from "./currencies.js";
3
+ import { isCurrencyCode, CURRENCY_CODES } from "./currencies.js";
4
4
  import { vmsg, reqErr, typeErr, reqTypeErr, jsonRefined, optionalize, jsonValue } from "./fields/validation.js";
5
5
  export { vmsg, reqErr, typeErr, reqTypeErr, jsonRefined, optionalize, jsonValue, decodeVmsg, validateField, } from "./fields/validation.js";
6
+ import { resolveParts, partColumns, partsRowShape, partValueKey, partValueKeys, stripServerOwnedParts, } from "./parts.js";
7
+ export { partValueKey, partValueKeys } from "./parts.js";
6
8
  export { normalizeOpts } from "./fields/normalize.js";
7
9
  import { normalizeOpts } from "./fields/normalize.js";
8
10
  import { presets } from "./field-presets.js";
@@ -127,8 +129,25 @@ export function wrapKey(opts, meta) {
127
129
  m = { ...m, default: opts.default };
128
130
  if (opts.hidden !== undefined)
129
131
  m = { ...m, hidden: opts.hidden };
132
+ if (opts.derive && opts.key && 'value' in opts && opts.value !== undefined)
133
+ throw new Error(`[wrapKey] '${opts.key}': both derive and value are set — they are two spellings of ` +
134
+ `"the server computes this field"; use value, the canonical spelling`);
130
135
  if (opts.derive)
131
136
  m = { ...m, derive: opts.derive, derived: true, hints: { ...m.hints, noEditControl: true } };
137
+ const computed = opts.key && 'value' in opts && opts.value !== undefined;
138
+ if (opts.derive && opts.store === false)
139
+ throw new Error(`[wrapKey] '${opts.key}': derive writes a column on every write, so store: false contradicts it; ` +
140
+ `spell the computation as \`value\` instead.`);
141
+ if (opts.store === false && !computed)
142
+ throw new Error(`[wrapKey] '${opts.key ?? '(keyless)'}': store: false needs key + value — it drops the COLUMN of a ` +
143
+ `computed field, so that the server recomputes it on every read. A field without \`value\` has ` +
144
+ `nothing to recompute; a field without \`key\` already stores nothing.`);
145
+ if (computed) {
146
+ m = { ...m, compute: opts.value, derived: true, hints: { ...m.hints, noEditControl: true } };
147
+ if (opts.store === false)
148
+ m = { ...m, virtual: true };
149
+ return { key: opts.key, type: m };
150
+ }
132
151
  if (opts.key)
133
152
  return { key: opts.key, type: m };
134
153
  if ('value' in opts && opts.value !== undefined)
@@ -595,6 +614,10 @@ export function check(raw) {
595
614
  };
596
615
  return wrapKey(o, meta);
597
616
  }
617
+ const MEASURED_ROLES = (units) => [
618
+ { role: 'value', accepts: ['number'], fallback: () => real({}) },
619
+ { role: 'unit', accepts: ['select', 'relation'], fallback: () => select({ options: units }) },
620
+ ];
598
621
  export function measured(raw) {
599
622
  const o = normalizeOpts(raw);
600
623
  const required = o.required ?? false;
@@ -608,15 +631,16 @@ export function measured(raw) {
608
631
  numSchema = numSchema.min(cfg.min);
609
632
  if (cfg.max != null)
610
633
  numSchema = numSchema.max(cfg.max);
611
- const rowSchema = z.object({
634
+ const parts = resolveParts(MEASURED_ROLES(o.options), o.parts);
635
+ const rowSchema = z.object(partsRowShape({
612
636
  value: numSchema,
613
637
  unit: z.string().refine((v) => units.some((u) => u.value === v), { message: vmsg('measured_unit') }),
614
- });
615
- const s = z.unknown().superRefine((raw, ctx) => {
638
+ }, parts, required !== true));
639
+ const s = z.unknown().transform((raw, ctx) => {
616
640
  const parsed = jsonValue(raw);
617
641
  if (typeof parsed === 'string') {
618
642
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: vmsg('json') });
619
- return;
643
+ return z.NEVER;
620
644
  }
621
645
  const r = rowSchema.safeParse(parsed);
622
646
  if (!r.success) {
@@ -632,7 +656,9 @@ export function measured(raw) {
632
656
  code: z.ZodIssueCode.custom,
633
657
  message: vmsgIssue ? vmsgIssue.message : vmsg('measured_structure'),
634
658
  });
659
+ return z.NEVER;
635
660
  }
661
+ return stripServerOwnedParts(r.data, parts);
636
662
  });
637
663
  const meta = {
638
664
  kind: 'measured',
@@ -646,7 +672,8 @@ export function measured(raw) {
646
672
  ...(cfg.max != null && { max: cfg.max }),
647
673
  ...(cfg.step != null && { step: cfg.step }),
648
674
  },
649
- columns: { value: 'real', unit: 'text' },
675
+ columns: partColumns(parts),
676
+ parts,
650
677
  zod: optionalize(s, required),
651
678
  };
652
679
  return wrapKey(o, meta);
@@ -655,9 +682,7 @@ export function unit(raw) {
655
682
  const o = normalizeOpts(raw);
656
683
  const required = o.required ?? false;
657
684
  const units = resolveUnits(o.options);
658
- const s = z
659
- .string(reqErr())
660
- .refine((v) => units.some((u) => u.value === v), { message: vmsg('measured_unit') });
685
+ const s = z.string(reqErr()).refine((v) => units.some((u) => u.value === v), { message: vmsg('measured_unit') });
661
686
  const meta = {
662
687
  kind: 'unit',
663
688
  label: o.label ?? '',
@@ -690,6 +715,10 @@ export function amount(raw) {
690
715
  };
691
716
  return wrapKey(o, meta);
692
717
  }
718
+ const MONEY_ROLES = [
719
+ { role: 'value', accepts: ['number'], fallback: () => real({}) },
720
+ { role: 'currency', accepts: ['select', 'relation'], fallback: () => select({ options: CURRENCY_CODES }) },
721
+ ];
693
722
  export function money(raw) {
694
723
  const o = normalizeOpts(raw);
695
724
  const required = o.required ?? false;
@@ -699,19 +728,23 @@ export function money(raw) {
699
728
  numSchema = numSchema.min(cfg.min);
700
729
  if (cfg.max != null)
701
730
  numSchema = numSchema.max(cfg.max);
702
- const rowSchema = z.object({
731
+ const parts = resolveParts(MONEY_ROLES, o.parts);
732
+ const rowSchema = z.object(partsRowShape({
703
733
  value: numSchema,
704
734
  currency: z.string().refine(isCurrencyCode, { message: vmsg('money_currency') }),
705
- });
706
- const s = z.unknown().superRefine((raw, ctx) => {
735
+ }, parts, required !== true));
736
+ const s = z.unknown().transform((raw, ctx) => {
707
737
  const parsed = jsonValue(raw);
708
738
  if (typeof parsed === 'string') {
709
739
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: vmsg('json') });
710
- return;
740
+ return z.NEVER;
711
741
  }
712
742
  const r = rowSchema.safeParse(parsed);
713
- if (!r.success)
743
+ if (!r.success) {
714
744
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: vmsg('money_structure') });
745
+ return z.NEVER;
746
+ }
747
+ return stripServerOwnedParts(r.data, parts);
715
748
  });
716
749
  const meta = {
717
750
  kind: 'money',
@@ -724,24 +757,34 @@ export function money(raw) {
724
757
  ...(cfg.min != null && { min: cfg.min }),
725
758
  ...(cfg.max != null && { max: cfg.max }),
726
759
  },
727
- columns: { value: 'real', currency: 'text' },
760
+ columns: partColumns(parts),
761
+ parts,
728
762
  zod: optionalize(s, required),
729
763
  };
730
764
  return wrapKey(o, meta);
731
765
  }
766
+ const CODE_ROLES = [
767
+ { role: 'code', accepts: ['text'], fallback: () => string({}) },
768
+ { role: 'lang', accepts: ['text'], fallback: () => string({}) },
769
+ ];
732
770
  export function code(raw) {
733
771
  const o = normalizeOpts(raw);
734
772
  const required = o.required ?? false;
735
773
  const cfg = o.config ?? {};
736
- const rowSchema = z.object({ code: z.string(), lang: z.string() });
737
- const s = z.unknown().superRefine((raw, ctx) => {
774
+ const parts = resolveParts(CODE_ROLES, o.parts);
775
+ const rowSchema = z.object(partsRowShape({ code: z.string(), lang: z.string() }, parts, required !== true));
776
+ const s = z.unknown().transform((raw, ctx) => {
738
777
  const parsed = jsonValue(raw);
739
778
  if (typeof parsed === 'string') {
740
779
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: vmsg('json') });
741
- return;
780
+ return z.NEVER;
742
781
  }
743
- if (!rowSchema.safeParse(parsed).success)
782
+ const r = rowSchema.safeParse(parsed);
783
+ if (!r.success) {
744
784
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: vmsg('code_structure') });
785
+ return z.NEVER;
786
+ }
787
+ return stripServerOwnedParts(r.data, parts);
745
788
  });
746
789
  const base = {
747
790
  kind: 'code',
@@ -750,33 +793,48 @@ export function code(raw) {
750
793
  prim: 'code',
751
794
  column: 'text',
752
795
  hints: { ...(cfg.defaultLang && { defaultLang: cfg.defaultLang }) },
753
- columns: { code: 'text', lang: 'text' },
796
+ columns: partColumns(parts),
797
+ parts,
754
798
  zod: optionalize(s, required),
755
799
  };
756
800
  return wrapKey(o, base);
757
801
  }
802
+ const GEO_ROLES = [
803
+ { role: 'lat', accepts: ['number'], fallback: () => real({}) },
804
+ { role: 'lng', accepts: ['number'], fallback: () => real({}) },
805
+ { role: 'label', accepts: ['text'], fallback: () => string({}) },
806
+ ];
758
807
  export function geo(raw) {
759
808
  const o = normalizeOpts(raw);
760
809
  const required = o.required ?? false;
761
- const rowSchema = z.object({
810
+ const parts = resolveParts(GEO_ROLES, o.parts);
811
+ const rowSchema = z.object(partsRowShape({
762
812
  lat: z.number().min(-90).max(90),
763
813
  lng: z.number().min(-180).max(180),
764
814
  label: z.string().nullish(),
765
- });
766
- const s = z.unknown().superRefine((raw, ctx) => {
815
+ }, parts, required !== true));
816
+ const clientCoordKeys = parts
817
+ .filter((p) => (p.role === 'lat' || p.role === 'lng') && p.mode === 'stored')
818
+ .map(partValueKey);
819
+ const s = z.unknown().transform((raw, ctx) => {
767
820
  const parsed = jsonValue(raw);
768
821
  const p = parsed;
769
- if (p == null || (typeof p === 'object' && p.lat == null && p.lng == null)) {
822
+ const noCoords = p == null || (typeof p === 'object' && clientCoordKeys.length > 0 && clientCoordKeys.every((k) => p[k] == null));
823
+ if (noCoords) {
770
824
  if (required)
771
825
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: vmsg('required') });
772
- return;
826
+ return raw;
773
827
  }
774
828
  if (typeof parsed === 'string') {
775
829
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: vmsg('json') });
776
- return;
830
+ return z.NEVER;
777
831
  }
778
- if (!rowSchema.safeParse(parsed).success)
832
+ const r = rowSchema.safeParse(parsed);
833
+ if (!r.success) {
779
834
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: vmsg('geo_structure') });
835
+ return z.NEVER;
836
+ }
837
+ return stripServerOwnedParts(r.data, parts);
780
838
  });
781
839
  const base = {
782
840
  kind: 'geo',
@@ -785,7 +843,8 @@ export function geo(raw) {
785
843
  prim: 'geo',
786
844
  column: 'text',
787
845
  hints: {},
788
- columns: { lat: 'real', lng: 'real', label: 'text' },
846
+ columns: partColumns(parts),
847
+ parts,
789
848
  zod: optionalize(s, required),
790
849
  };
791
850
  return wrapKey(o, base);
@@ -802,23 +861,21 @@ export function perWeekday(o) {
802
861
  kind: 'per-weekday',
803
862
  };
804
863
  }
805
- export function lookup(raw) {
806
- const o = normalizeOpts(raw);
807
- const meta = {
808
- kind: 'lookup',
809
- label: o.label ?? '',
810
- required: false,
811
- prim: 'lookup',
812
- column: 'text',
813
- virtual: true,
814
- hints: { from: o.from, pick: o.pick, compareWith: o.compareWith ?? null },
815
- zod: z.any().optional(),
816
- };
817
- return wrapKey(o, meta);
818
- }
819
864
  export function clientToMeta(fc) {
820
- return { ...fc, column: 'text', zod: z.any() };
821
- }
865
+ const { parts: _parts, ...rest } = fc;
866
+ return { ...rest, column: 'text', zod: z.any() };
867
+ }
868
+ function endpointReader(parts, keys) {
869
+ const pinned = new Map();
870
+ for (const p of parts)
871
+ if (p.mode === 'pinned')
872
+ pinned.set(p.role, p.value);
873
+ return (role, stored) => (pinned.has(role) ? pinned.get(role) : stored?.[keys[role]]);
874
+ }
875
+ const PERIOD_ROLES = (mkSub) => [
876
+ { role: 'from', accepts: ['date', 'datetime'], fallback: () => mkSub('from', 'core.period.from').type },
877
+ { role: 'until', accepts: ['date', 'datetime'], fallback: () => mkSub('until', 'core.period.until').type },
878
+ ];
822
879
  export function period(raw) {
823
880
  const o = normalizeOpts(raw);
824
881
  const granularity = (o.config?.granularity ?? 'day');
@@ -839,9 +896,13 @@ export function period(raw) {
839
896
  const fromField = mkSub('from', 'core.period.from');
840
897
  const untilField = mkSub('until', 'core.period.until');
841
898
  const subDefs = { from: fromField.type, until: untilField.type };
842
- const rowShape = {};
899
+ const parts = resolveParts(PERIOD_ROLES(mkSub), o.parts);
900
+ const periodKeys = partValueKeys(parts);
901
+ const endpoint = endpointReader(parts, periodKeys);
902
+ const defaultShape = {};
843
903
  for (const [key, fm] of Object.entries(subDefs))
844
- rowShape[key] = fm.zod.optional();
904
+ defaultShape[key] = fm.zod.optional();
905
+ const rowShape = partsRowShape(defaultShape, parts);
845
906
  let s = z.object(rowShape).passthrough();
846
907
  s = s.superRefine((val, ctx) => {
847
908
  if (val == null)
@@ -857,16 +918,18 @@ export function period(raw) {
857
918
  }
858
919
  const items = Array.isArray(parsed) ? parsed : [parsed];
859
920
  for (const item of items) {
860
- const r = item;
861
- if (r?.from && r?.until && String(r.from) > String(r.until))
921
+ const obj = item;
922
+ const r = obj != null && typeof obj === 'object' && !Array.isArray(obj) ? stripServerOwnedParts(obj, parts) : obj;
923
+ const f = endpoint('from', r);
924
+ const u = endpoint('until', r);
925
+ if (f && u && String(f) > String(u))
862
926
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: vmsg('period_order') });
863
927
  }
864
928
  });
865
- const colType = granularity === 'datetime' ? 'datetime' : granularity === 'day' ? 'date' : 'text';
866
- const subFields = [
867
- { key: 'from', ...toClient(fromField.type) },
868
- { key: 'until', ...toClient(untilField.type) },
869
- ];
929
+ s = s.transform((val) => val != null && typeof val === 'object' && !Array.isArray(val)
930
+ ? stripServerOwnedParts(val, parts)
931
+ : val);
932
+ const subFields = parts.map((p) => ({ key: partValueKey(p), ...toClient(p.meta) }));
870
933
  const meta = {
871
934
  kind: 'period',
872
935
  label: o.label ?? '',
@@ -874,7 +937,8 @@ export function period(raw) {
874
937
  prim: 'period',
875
938
  column: 'text',
876
939
  hints: { fields: subFields, rowMultiple: false, unique: [], granularity },
877
- columns: { from: colType, until: colType },
940
+ columns: partColumns(parts),
941
+ parts,
878
942
  zod: optionalize(s, required),
879
943
  };
880
944
  return wrapKey(o, meta);
@@ -978,35 +1042,46 @@ export function keyValue(raw) {
978
1042
  required: o.required,
979
1043
  });
980
1044
  }
981
- function makeRange(kind, int, raw) {
1045
+ const RANGE_ROLES = (isInt) => [
1046
+ { role: 'from', accepts: ['number'], fallback: () => (isInt ? int({}) : real({})) },
1047
+ { role: 'to', accepts: ['number'], fallback: () => (isInt ? int({}) : real({})) },
1048
+ ];
1049
+ function makeRange(kind, isInt, raw) {
982
1050
  const o = normalizeOpts(raw);
983
1051
  const required = o.required ?? false;
984
1052
  const cfg = o.config ?? {};
985
1053
  let n = z.number().finite();
986
- if (int)
1054
+ if (isInt)
987
1055
  n = n.int({ message: vmsg('int') });
988
1056
  if (cfg.min != null)
989
1057
  n = n.min(cfg.min);
990
1058
  if (cfg.max != null)
991
1059
  n = n.max(cfg.max);
992
- const rowSchema = z.object({ from: n, to: n });
993
- const s = z.unknown().superRefine((raw, ctx) => {
1060
+ const parts = resolveParts(RANGE_ROLES(isInt), o.parts);
1061
+ const rangeKeys = partValueKeys(parts);
1062
+ const rowSchema = z.object(partsRowShape({ from: n, to: n }, parts, required !== true));
1063
+ const endpoint = endpointReader(parts, rangeKeys);
1064
+ const s = z.unknown().transform((raw, ctx) => {
994
1065
  if (typeof raw === 'string') {
995
1066
  try {
996
1067
  JSON.parse(raw);
997
1068
  }
998
1069
  catch {
999
1070
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: vmsg('json') });
1000
- return;
1071
+ return z.NEVER;
1001
1072
  }
1002
1073
  }
1003
1074
  const r = rowSchema.safeParse(jsonValue(raw));
1004
1075
  if (!r.success) {
1005
1076
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: vmsg('range_structure') });
1006
- return;
1077
+ return z.NEVER;
1007
1078
  }
1008
- if (r.data.from > r.data.to)
1079
+ const stored = stripServerOwnedParts(r.data, parts);
1080
+ const fromV = endpoint('from', stored);
1081
+ const toV = endpoint('to', stored);
1082
+ if (fromV != null && toV != null && fromV > toV)
1009
1083
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: vmsg('range_order') });
1084
+ return stored;
1010
1085
  });
1011
1086
  const base = {
1012
1087
  kind,
@@ -1019,7 +1094,8 @@ function makeRange(kind, int, raw) {
1019
1094
  ...(cfg.max != null && { max: cfg.max }),
1020
1095
  ...(cfg.step != null && { step: cfg.step }),
1021
1096
  },
1022
- columns: int ? { from: 'integer', to: 'integer' } : { from: 'real', to: 'real' },
1097
+ columns: partColumns(parts),
1098
+ parts,
1023
1099
  zod: optionalize(s, required),
1024
1100
  };
1025
1101
  return wrapKey(o, base);
@@ -1082,7 +1158,6 @@ const PRIMITIVES = {
1082
1158
  money,
1083
1159
  code,
1084
1160
  geo,
1085
- lookup,
1086
1161
  perWeekday,
1087
1162
  period,
1088
1163
  file,
@@ -1122,6 +1197,24 @@ export const field = new Proxy({}, {
1122
1197
  has: (_t, k) => k in composedField(),
1123
1198
  });
1124
1199
  export function toClient(field) {
1125
- const { kind, label, required, prim, hints, options, relation, virtual, json, hidden, derived } = field;
1126
- return { kind, label, required, prim, hints, options, relation, virtual, json, hidden, derived };
1200
+ const { kind, label, required, prim, hints, options, relation, virtual, json, hidden, derived, parts } = field;
1201
+ return {
1202
+ kind,
1203
+ label,
1204
+ required,
1205
+ prim,
1206
+ hints,
1207
+ options,
1208
+ relation,
1209
+ virtual,
1210
+ json,
1211
+ hidden,
1212
+ derived,
1213
+ parts: parts?.map((p) => ({
1214
+ role: p.role,
1215
+ key: p.key,
1216
+ mode: p.mode,
1217
+ ...(typeof p.value !== 'function' && p.value !== undefined && { value: p.value }),
1218
+ })),
1219
+ };
1127
1220
  }
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ import { type ColumnType, type FieldItem, type FieldMeta, type StaticEl } from './fields.ts';
3
+ export interface RoleDef {
4
+ role: string;
5
+ accepts: string[];
6
+ fallback: () => FieldMeta;
7
+ }
8
+ export type PartInput = FieldItem | StaticEl | FieldMeta;
9
+ export type PartsSpec = Record<string, PartInput>;
10
+ export type PartMode = 'stored' | 'pinned' | 'computed' | 'computedStored';
11
+ export interface ResolvedPart {
12
+ role: string;
13
+ key: string | null;
14
+ meta: FieldMeta;
15
+ mode: PartMode;
16
+ overridden: boolean;
17
+ value?: unknown;
18
+ }
19
+ export declare function partValueKey(p: PartSlot): string;
20
+ export interface PartSlot {
21
+ role: string;
22
+ key: string | null;
23
+ }
24
+ export declare function partValueKeys(parts: PartSlot[]): Record<string, string>;
25
+ export declare function resolveParts(roles: RoleDef[], spec: PartsSpec | undefined): ResolvedPart[];
26
+ export declare function partColumns(parts: ResolvedPart[]): Record<string, ColumnType>;
27
+ export declare function partsRowShape(shape: Record<string, z.ZodTypeAny>, parts: ResolvedPart[], tolerateNull?: boolean): Record<string, z.ZodTypeAny>;
28
+ export declare function mixedOwnedStoredParts(parts: ResolvedPart[]): {
29
+ server: string[];
30
+ client: string[];
31
+ } | null;
32
+ export declare function mandatedClientParts(required: unknown, parts: ResolvedPart[]): string[];
33
+ export declare function stripServerOwnedParts(value: Record<string, unknown>, parts: ResolvedPart[]): Record<string, unknown>;
package/dist/parts.js ADDED
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod';
2
+ import { isField, isStatic } from "./fields.js";
3
+ export function partValueKey(p) {
4
+ return p.key ?? p.role;
5
+ }
6
+ export function partValueKeys(parts) {
7
+ const out = {};
8
+ for (const p of parts)
9
+ out[p.role] = partValueKey(p);
10
+ return out;
11
+ }
12
+ const NUMERIC_LIKE = /^\d+$/;
13
+ function readInput(input) {
14
+ if (isField(input)) {
15
+ const it = input;
16
+ return { key: it.key, meta: it.type, value: it.type.compute };
17
+ }
18
+ if (isStatic(input)) {
19
+ const el = input;
20
+ return { key: null, meta: el.type, value: el.value };
21
+ }
22
+ return { key: null, meta: input, value: undefined };
23
+ }
24
+ function modeOf(key, value) {
25
+ if (key && value !== undefined)
26
+ return 'computedStored';
27
+ if (key)
28
+ return 'stored';
29
+ return typeof value === 'function' ? 'computed' : 'pinned';
30
+ }
31
+ export function resolveParts(roles, spec) {
32
+ const known = new Set(roles.map((r) => r.role));
33
+ for (const name of Object.keys(spec ?? {})) {
34
+ if (NUMERIC_LIKE.test(name))
35
+ throw new Error(`parts: numeric-like role name '${name}' (object iteration would reorder the parts)`);
36
+ if (!known.has(name))
37
+ throw new Error(`parts: unknown role '${name}' (roles: ${[...known].join(', ')})`);
38
+ }
39
+ const out = [];
40
+ const usedKeys = new Set();
41
+ for (const def of roles) {
42
+ const input = spec?.[def.role];
43
+ const { key, meta, value } = input ? readInput(input) : { key: def.role, meta: def.fallback(), value: undefined };
44
+ if (!def.accepts.includes(meta.prim))
45
+ throw new Error(`parts: role '${def.role}' does not accept prim '${meta.prim}' (accepts: ${def.accepts.join(', ')})`);
46
+ const storageKey = key ?? (value === undefined ? def.role : null);
47
+ const mode = modeOf(storageKey, value);
48
+ if (storageKey && storageKey !== def.role && known.has(storageKey))
49
+ throw new Error(`parts: role '${def.role}' filling declares key '${storageKey}', which is another role of this composite — ` +
50
+ `a stored part's key must not collide with a role name (an unstored role occupies its role name in the ` +
51
+ `value object).`);
52
+ if (storageKey) {
53
+ if (usedKeys.has(storageKey))
54
+ throw new Error(`parts: duplicate storage key '${storageKey}'`);
55
+ usedKeys.add(storageKey);
56
+ }
57
+ out.push({
58
+ role: def.role,
59
+ key: storageKey,
60
+ meta,
61
+ mode,
62
+ overridden: input !== undefined,
63
+ ...(value !== undefined && { value }),
64
+ });
65
+ }
66
+ return out;
67
+ }
68
+ export function partColumns(parts) {
69
+ const cols = {};
70
+ for (const p of parts)
71
+ if (p.key)
72
+ cols[p.key] = p.meta.column;
73
+ return cols;
74
+ }
75
+ export function partsRowShape(shape, parts, tolerateNull = false) {
76
+ const out = { ...shape };
77
+ for (const p of parts)
78
+ if (partValueKey(p) !== p.role)
79
+ delete out[p.role];
80
+ for (const p of parts) {
81
+ if (!p.key)
82
+ out[p.role] = z.unknown().optional();
83
+ else if (p.mode === 'computedStored')
84
+ out[p.key] = z.unknown().optional();
85
+ else {
86
+ const own = p.overridden ? p.meta.zod : (shape[p.role] ?? z.unknown().optional());
87
+ out[p.key] = tolerateNull && !(p.overridden && p.meta.required === true) ? own.nullable() : own;
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ export function mixedOwnedStoredParts(parts) {
93
+ const server = parts.filter((p) => p.mode === 'computedStored').map((p) => p.role);
94
+ const client = parts.filter((p) => p.mode === 'stored').map((p) => p.role);
95
+ return server.length > 0 && client.length > 0 ? { server, client } : null;
96
+ }
97
+ export function mandatedClientParts(required, parts) {
98
+ const speaks = (flag) => flag !== undefined && flag !== false;
99
+ const field = speaks(required);
100
+ return parts
101
+ .filter((p) => p.mode === 'stored' && (field || (p.overridden && speaks(p.meta.required))))
102
+ .map((p) => p.role);
103
+ }
104
+ function serverOwnedSlot(p) {
105
+ if (!p.key)
106
+ return p.role;
107
+ return p.mode === 'computedStored' ? p.key : null;
108
+ }
109
+ export function stripServerOwnedParts(value, parts) {
110
+ let out = value;
111
+ for (const p of parts) {
112
+ const slot = serverOwnedSlot(p);
113
+ if (slot != null && slot in out) {
114
+ if (out === value)
115
+ out = { ...value };
116
+ delete out[slot];
117
+ }
118
+ }
119
+ return out;
120
+ }
package/dist/shelf.d.ts CHANGED
@@ -28,12 +28,14 @@ export interface ShelfDef {
28
28
  export declare function fieldEntries(items: LayoutEl[]): [string, FieldMeta][];
29
29
  export declare function storageColumns(key: string, field: FieldMeta): [string, ColumnType][];
30
30
  export declare function cellValue(field: FieldMeta, key: string, row: Record<string, unknown>): unknown;
31
+ export declare function magnitudeSub(field: FieldMeta): string | undefined;
31
32
  export interface CollectionEntry {
32
33
  key: string;
33
34
  group: GroupEl;
34
35
  }
35
36
  export declare function collectionGroups(items: LayoutEl[]): CollectionEntry[];
36
37
  export declare function fieldMap(items: LayoutEl[]): Record<string, FieldMeta>;
38
+ export declare function checkFieldDeclaration(fields: LayoutEl[], owner: string): void;
37
39
  export declare function defineShelf(m: ShelfDef): ShelfDef;
38
40
  export declare const SYSTEM_FIELDS: readonly ["id", "createdAt", "updatedAt"];
39
41
  export declare function titleKey(m: ShelfDef): string;
package/dist/shelf.js CHANGED
@@ -3,7 +3,8 @@ import { toClient, isField, isGroup, isStatic, isCollectionGroup, isEmbeddedGrou
3
3
  import { resolveFlag } from "./condition.js";
4
4
  import { parseUrl } from "./fields/url.js";
5
5
  import { vmsg } from "./fields/validation.js";
6
- import { derivedEntries, derivableKeys } from "./derive.js";
6
+ import { derivableKeys, legacyDeriveDeps } from "./derive.js";
7
+ import { mixedOwnedStoredParts, mandatedClientParts } from "./parts.js";
7
8
  export function fieldEntries(items) {
8
9
  const out = [];
9
10
  for (const it of items) {
@@ -34,14 +35,21 @@ export function cellValue(field, key, row) {
34
35
  const out = {};
35
36
  let filled = false;
36
37
  for (const sub of Object.keys(field.columns)) {
37
- const v = row[`${key}__${sub}`];
38
- if (v == null || v === '')
38
+ const col = `${key}__${sub}`;
39
+ if (!(col in row))
39
40
  continue;
41
+ const v = row[col];
40
42
  out[sub] = v;
41
- filled = true;
43
+ if (v != null && v !== '')
44
+ filled = true;
42
45
  }
43
46
  return filled ? out : undefined;
44
47
  }
48
+ export function magnitudeSub(field) {
49
+ if (!field.columns)
50
+ return undefined;
51
+ return Object.keys(field.columns)[0];
52
+ }
45
53
  export function collectionGroups(items) {
46
54
  const out = [];
47
55
  for (const it of items) {
@@ -65,6 +73,41 @@ export function fieldMap(items) {
65
73
  }
66
74
  return m;
67
75
  }
76
+ function assertNoKeylessCompute(items, owner) {
77
+ for (const it of items) {
78
+ if (isStatic(it) && typeof it.value === 'function')
79
+ throw new Error(`${owner}: computed element needs a key — the server computes it and needs a name to return it under`);
80
+ if (isGroup(it))
81
+ assertNoKeylessCompute(it.fields, owner);
82
+ }
83
+ }
84
+ function warnMixedOwnedParts(items, owner, path = '') {
85
+ for (const it of items) {
86
+ if (isGroup(it)) {
87
+ warnMixedOwnedParts(it.fields, owner, it.key ? `${path}${it.key}.` : path);
88
+ continue;
89
+ }
90
+ if (!isField(it) || !it.type.parts)
91
+ continue;
92
+ const mixed = mixedOwnedStoredParts(it.type.parts);
93
+ if (!mixed)
94
+ continue;
95
+ const mandated = mandatedClientParts(it.type.required, it.type.parts);
96
+ if (!mandated.length)
97
+ continue;
98
+ console.warn(`${owner}: composite '${path}${it.key}' mandates a client-owned stored part (${mandated.join(', ')}) ` +
99
+ `and mixes it with a server-owned stored part (${mixed.server.join(', ')}) — ` +
100
+ `a mandated part is the client's to declare, so the write path does NOT materialize this composite ` +
101
+ `and the computed part stays absent until a client sends the field. Either drop \`required\` ` +
102
+ `(from the field or from the ${mandated.join('/')} filling), or give every stored part a \`value\` ` +
103
+ `(the composite becomes fully server-owned), or drop the \`key\` of ` +
104
+ `${mixed.client.join('/')} so it is unstored (pinned/computed and injected on read).`);
105
+ }
106
+ }
107
+ export function checkFieldDeclaration(fields, owner) {
108
+ assertNoKeylessCompute(fields, owner);
109
+ warnMixedOwnedParts(fields, owner);
110
+ }
68
111
  export function defineShelf(m) {
69
112
  const keys = fieldEntries(m.fields).map(([k]) => k);
70
113
  const dup = keys.find((k, i) => keys.indexOf(k) !== i);
@@ -86,9 +129,10 @@ export function defineShelf(m) {
86
129
  console.warn(`[shelf] ${m.library}/${m.shelf}: ${titles.length} title fields (${titles.map(([k]) => k).join(', ')}) — '${titles[0][0]}' wins`);
87
130
  if (m.standalone !== false && !m.single && titleKey(m) === 'id')
88
131
  console.warn(`[shelf] ${m.library}/${m.shelf}: no title declared (field.title() or views.title) — the record renders no heading`);
132
+ checkFieldDeclaration(m.fields, `[shelf] ${m.library}/${m.shelf}`);
89
133
  const known = derivableKeys(m.fields);
90
- for (const [key, spec] of derivedEntries(m.fields)) {
91
- for (const dep of spec.deps) {
134
+ for (const [key, deps] of legacyDeriveDeps(m.fields)) {
135
+ for (const dep of deps) {
92
136
  if (!known.has(dep))
93
137
  throw new Error(`[defineShelf] ${m.library}/${m.shelf}: derive on '${key}' depends on unknown key '${dep}'`);
94
138
  }
@@ -10,6 +10,7 @@ export interface ShowcaseDef {
10
10
  };
11
11
  filter: Condition;
12
12
  columns: string[];
13
+ defaultGroupBy?: string;
13
14
  createDefaults?: Record<string, unknown>;
14
15
  }
15
16
  export declare function defineShowcase(s: ShowcaseDef): ShowcaseDef;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/sdk",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"