js-bao 0.5.1 → 0.5.2

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.
@@ -563,6 +563,27 @@ declare class BaseModel implements StringSetChangeTracker {
563
563
  */
564
564
  static count(this: new (...args: any[]) => any, filter?: DocumentFilter, options?: Pick<QueryOptions, "documents">): Promise<number>;
565
565
  static findAll<T extends BaseModel>(this: new (...args: any[]) => T): Promise<T[]>;
566
+ /**
567
+ * Resolve a record by its unique-constraint field values via an engine query.
568
+ * Used as a fallback when the `_uniqueIdx_<model>_<constraint>` map has no
569
+ * entry for a key.
570
+ *
571
+ * The index map is a cache over the SQLite projection, not the source of
572
+ * truth: every record is loaded into the projection on `initializeForDocument`
573
+ * regardless of index presence, but a record written by a client predating
574
+ * index maintenance (legacy data) is missing from the index. Such a record
575
+ * silently escapes both `upsertByUnique` lookups and the `save()` uniqueness
576
+ * check. A query over the constraint fields finds it reliably.
577
+ *
578
+ * Contract: RESOLVE ONLY. Returns the owning record (with `_metaDocId` set to
579
+ * the document that holds it, via `queryOne`) or `null`. Callers decide
580
+ * whether to heal the missing index entry (write paths) and do their own
581
+ * logging — this method never mutates the index, so read paths stay
582
+ * read-only. Mirrors `buildUniqueKey`: a null/undefined value in any
583
+ * constraint field means the record does not participate in the constraint,
584
+ * so no query runs.
585
+ */
586
+ private static _resolveByUniqueFallback;
566
587
  static findByUnique<T extends BaseModel>(this: typeof BaseModel & (new (...args: any[]) => T), constraintName: string, value: any | any[]): Promise<T | null>;
567
588
  static upsertByUnique<T extends BaseModel>(this: typeof BaseModel & (new (...args: any[]) => T), constraintName: string, uniqueLookupValue: any | any[], dataToUpsert: Partial<Omit<InstanceType<new (...args: any[]) => T>, keyof BaseModel | "toJSON">> & {
568
589
  id?: string;
@@ -733,6 +754,18 @@ interface HookResult {
733
754
  allow: boolean;
734
755
  /** Reason for denial (returned to client as error message) */
735
756
  reason?: string;
757
+ /**
758
+ * Names of fields the hook wrote onto `ctx.data` that the DO should echo
759
+ * back to the caller in the write response's `appliedFields`. A `beforeSave`
760
+ * hook can compute and set fields on `ctx.data` (e.g. app-level triggers that
761
+ * derive a value from the submitted row); those values are persisted but are
762
+ * invisible to a caller that rebuilds a change event from its original
763
+ * submission inputs. Reporting the names here lets the DO read the final
764
+ * post-hook values back and thread them into `appliedFields` so the caller
765
+ * can propagate them (#1476). The hook contract stays generic — it names the
766
+ * fields it applied and does not need to know how the caller uses them.
767
+ */
768
+ serverAppliedFieldNames?: string[];
736
769
  }
737
770
  /** Result from beforeQuery hooks — can inject additional filters */
738
771
  interface BeforeQueryResult extends HookResult {
@@ -849,6 +882,20 @@ interface QueryResponse {
849
882
  interface SaveResponse {
850
883
  success: boolean;
851
884
  id: string;
885
+ /**
886
+ * Server-applied field values the DO wrote AFTER the caller's submission
887
+ * inputs were assembled, read back from the final post-hook `data`
888
+ * (#1476, consolidating the #1462 `stamps` channel). This is the single
889
+ * "server-applied fields" channel: it carries both the applied `autoStamps`
890
+ * timestamps and any fields a `beforeSave` hook reported via
891
+ * `serverAppliedFieldNames` (e.g. app-level trigger-computed values). Present
892
+ * only when at least one such field ended up with a value. Lets the Worker
893
+ * thread these values into the change-event `data` it broadcasts to
894
+ * subscribers — otherwise a `select`-listed stamped or trigger-computed field
895
+ * never reaches the frame, because the frame is rebuilt from the submission
896
+ * inputs (which predate the DO-side write).
897
+ */
898
+ appliedFields?: Record<string, any>;
852
899
  }
853
900
  interface DeleteResponse {
854
901
  success: boolean;
@@ -931,6 +978,8 @@ interface PatchRequest {
931
978
  interface PatchResponse {
932
979
  success: boolean;
933
980
  id: string;
981
+ /** Server-applied field values (#1476). See `SaveResponse.appliedFields`. */
982
+ appliedFields?: Record<string, any>;
934
983
  }
935
984
  interface BatchOperation {
936
985
  op: "save" | "patch" | "delete" | "increment" | "addToSet" | "removeFromSet";
@@ -958,12 +1007,46 @@ interface BatchOperation {
958
1007
  }
959
1008
  interface BatchRequest {
960
1009
  operations: BatchOperation[];
1010
+ /**
1011
+ * All-or-nothing execution (#1478). When `true`, the FIRST operation that
1012
+ * fails inside the transaction — hook denial, condition / `ifNotExists` /
1013
+ * unique violation, record-not-found, or upsert validation error — aborts
1014
+ * the whole batch: every write is rolled back and the handler returns an
1015
+ * error naming the failed step with its natural HTTP status (see
1016
+ * `BatchOperationResult.status`). When `false` or absent (the default), each
1017
+ * operation's outcome is recorded independently and successful siblings
1018
+ * commit — the partial-success behavior import-bulk and applyToQuery rely on.
1019
+ */
1020
+ atomic?: boolean;
961
1021
  }
1022
+ /**
1023
+ * Stable, machine-readable failure tag on a failed `BatchOperationResult`
1024
+ * (#1478). Derived at each failure site — never inferred from the `error`
1025
+ * string. `#1389` (conditional writes + fencing-token enforcement) surfaces its
1026
+ * condition / stale-token rejections through this same channel.
1027
+ */
1028
+ type BatchFailureCode = "NOT_FOUND" | "HOOK_DENIED" | "CONDITION_NOT_MET" | "ALREADY_EXISTS" | "UNIQUE_VIOLATION" | "VALIDATION_ERROR";
962
1029
  interface BatchOperationResult {
963
1030
  success: boolean;
964
1031
  id: string;
965
1032
  error?: string;
1033
+ /**
1034
+ * Structured failure metadata (#1478), present only on a failed result. Both
1035
+ * are derived at the failure site, not parsed back out of `error`:
1036
+ * - `status` — the natural HTTP status for the cause (404 not found,
1037
+ * 403 hook denial, 409 condition / `ifNotExists` / unique conflict,
1038
+ * 400 upsert validation).
1039
+ * - `code` — a stable machine-readable tag (see `BatchFailureCode`).
1040
+ *
1041
+ * In atomic mode the handler reads the first failure's `status`/`code` to
1042
+ * build the error response, so an increment-on-missing op keeps its 404 and a
1043
+ * hook denial keeps its 403 instead of collapsing to a uniform 409.
1044
+ */
1045
+ status?: number;
1046
+ code?: BatchFailureCode;
966
1047
  values?: Record<string, number>;
1048
+ /** Server-applied field values (#1476). See `SaveResponse.appliedFields`. */
1049
+ appliedFields?: Record<string, any>;
967
1050
  }
968
1051
  interface BatchResponse {
969
1052
  results: BatchOperationResult[];
@@ -1604,6 +1687,41 @@ interface LoadSchemaOptions {
1604
1687
  */
1605
1688
  declare function loadSchemaFromTomlString(tomlString: string, options?: LoadSchemaOptions): DefinedModelSchema[];
1606
1689
 
1690
+ /**
1691
+ * Unique-constraint index helpers (shared CRDT contract).
1692
+ *
1693
+ * js-bao enforces unique constraints inside the Yjs document, not via a SQL
1694
+ * `UNIQUE`. Each constraint has a `_uniqueIdx_<model>_<constraint>` map
1695
+ * (uniqueKey -> surviving recordId) and a document observer that
1696
+ * deterministically discards duplicate losers. Both the client write path and
1697
+ * the server-direct write path (`mutateRecord` in js-bao-wss) must derive the
1698
+ * key and the index map name the SAME way, or a server-written record would
1699
+ * not be seen by the client-side resolver. These helpers are the single source
1700
+ * of truth for that derivation so neither side keeps a parallel copy.
1701
+ */
1702
+
1703
+ /**
1704
+ * Build the unique-constraint key for a record over the given fields.
1705
+ *
1706
+ * Mirrors the document-observer conflict resolver: a `null`/`undefined` value
1707
+ * in any field means the record does not participate in the constraint (returns
1708
+ * `null`); otherwise the per-field `String()` values joined by `"|"`. This is
1709
+ * the key the `_uniqueIdx_*` map is read/written with during conflict
1710
+ * resolution, so it is the key the resolver treats as authoritative.
1711
+ */
1712
+ declare function buildUniqueKey(recordData: Y.Map<any> | Record<string, any>, fields: string[]): string | null;
1713
+ /**
1714
+ * Name of the Yjs map that indexes a unique constraint within a document:
1715
+ * `_uniqueIdx_<model>_<constraint>` mapping uniqueKey -> surviving recordId.
1716
+ */
1717
+ declare function uniqueIndexMapName(modelName: string, constraintName: string): string;
1718
+ /**
1719
+ * The constraint name js-bao assigns to a single-field `unique: true` field
1720
+ * (see `resolveUniqueConstraints` in models/schema.ts). Compound constraints
1721
+ * keep their author-given name instead.
1722
+ */
1723
+ declare function fieldUniqueConstraintName(modelName: string, fieldName: string): string;
1724
+
1607
1725
  /**
1608
1726
  * Meta Sync — writes _meta_* YMaps into a YDoc.
1609
1727
  *
@@ -1656,4 +1774,161 @@ declare function syncModelMeta(yDoc: Y.Doc, modelName: string, schema: ModelSche
1656
1774
  */
1657
1775
  declare function syncInferredMeta(yDoc: Y.Doc, modelName: string, recordData: Record<string, any>): void;
1658
1776
 
1659
- export { type AfterQueryContext, type AfterQueryResult, type AggregateRequest, type AggregateResponse, type AggregationOperation, type AggregationOptions, type AggregationResult, type BatchOperation, type BatchOperationResult, type BatchRequest, type BatchResponse, type BeforeDeleteContext, type BeforeQueryContext, type BeforeQueryResult, type BeforeSaveContext, type ConnectDoDbOptions, type CountRequest, type CountResponse, DOClientEngine, type DOClientEngineConfig, type DatabaseDOHooks, type DeleteRequest, type DeleteResponse, type DiscoveredConstraint, type DiscoveredField, type DiscoveredModel, type DiscoveredRelationship, type DiscoveredSchema, type DoDb, type DocumentFilter, type DropIndexRequest, type DropIndexResponse, type DropUniqueConstraintRequest, type DropUniqueConstraintResponse, type ErrorResponse, type GroupByField, type HookContext, type HookResult, type IncrementRequest, type IncrementResponse, type IndexEntry, type IndexListResponse, type InitJsBaoDOOptions, type InitJsBaoDOResult, type ModelAccessor, type PaginatedResult, type PatchOptions, type PatchRequest, type PatchResponse, type ProjectionSpec, type QueryOptions, type QueryRequest, type QueryResponse, type RegisterIndexRequest, type RegisterIndexResponse, type RegisterUniqueConstraintRequest, type RegisterUniqueConstraintResponse, type SaveOptions, type SaveRequest, type SaveResponse, type SortSpec, type StringSetMembership, type StringSetUpdateRequest, type StringSetUpdateResponse, type SyncIndexesRequest, type SyncIndexesResponse, type UniqueConstraintEntry, type UniqueConstraintListResponse, type WriteCondition, clearMetaSyncCache, connectDoDb, discoverModelNames, discoverSchema, getActiveDOEngine, inferFieldType, initJsBaoDO, loadSchemaFromTomlString, registerFunctionDefault, resetJsBaoDO, schemaToToml, syncInferredMeta, syncModelMeta };
1777
+ /**
1778
+ * Write lifecycle — shared, schema-keyed write behavior for both the js-bao
1779
+ * client (`BaseModel.save()`) and the server-direct workflow write path
1780
+ * (`mutateRecord` in js-bao-wss `src/yjs-room-v2.ts`).
1781
+ *
1782
+ * Background (#1246): the server re-implemented js-bao's client write behavior,
1783
+ * so the two paths could drift and new js-bao field types were not picked up by
1784
+ * workflow writes automatically. This module is the single source of truth for
1785
+ * the *pure transform* (data + schema → Yjs structure), keyed only off schema
1786
+ * metadata — no ORM concerns (reactivity, local-change tracking, document
1787
+ * connection). Both paths call these helpers, so:
1788
+ *
1789
+ * 1. the stringset Yjs-shape primitives are written the same way on both sides;
1790
+ * 2. schema `default`s and `autoStamp`s are applied the same way on both sides;
1791
+ * 3. a new `FieldType` breaks `fieldWriteKind`'s exhaustive switch until it is
1792
+ * classified once — and the server, calling the same core, inherits it.
1793
+ *
1794
+ * The unique-index contract already lives in `../utils/uniqueIndex.ts`; this
1795
+ * module is the same "neither side keeps a parallel copy" pattern generalized
1796
+ * to the rest of the write lifecycle.
1797
+ */
1798
+
1799
+ /**
1800
+ * Compile-time exhaustiveness guard. Passing a value whose type is not `never`
1801
+ * is a TypeScript error, so an unhandled `case` in an exhaustive `switch` fails
1802
+ * `tsc`. At runtime (reached only for a genuinely unknown value) it throws.
1803
+ */
1804
+ declare function assertNever(x: never): never;
1805
+ /**
1806
+ * How a field's declared type is written into the record Y.Map. Every member
1807
+ * of the `FieldType` union must be classified here; adding a new field type to
1808
+ * the union without a `case` below fails `pnpm typecheck` (via `assertNever`),
1809
+ * which is what forces both write paths to handle it. (#1246, layer 2.)
1810
+ */
1811
+ type FieldWriteKind = "scalar" | "stringset";
1812
+ declare function fieldWriteKind(type: FieldType): FieldWriteKind;
1813
+ /** True if `type` is a member of the `FieldType` union. */
1814
+ declare function isKnownFieldType(type: string): type is FieldType;
1815
+ /**
1816
+ * Resolve (or migrate to) the canonical nested `Y.Map<boolean>` for a
1817
+ * `stringset` field. A legacy plain-object value is carried over member by
1818
+ * member; the nested map is never destroyed and rebuilt, so a concurrent
1819
+ * client `.add()`/`.delete()` merges instead of being clobbered.
1820
+ */
1821
+ declare function ensureStringSetYMap(rm: Y.Map<any>, fieldName: string): Y.Map<boolean>;
1822
+ interface StringSetDelta {
1823
+ add?: Iterable<string>;
1824
+ remove?: Iterable<string>;
1825
+ clear?: boolean;
1826
+ }
1827
+ /**
1828
+ * Apply a per-member DELTA to a stringset target map. `clear` empties the set
1829
+ * first; then `remove` deletes, then `add` sets — so an `add` wins over a
1830
+ * `remove` of the same member within one delta. Members not named are left
1831
+ * untouched, so two concurrent `add`s of different members merge cleanly.
1832
+ */
1833
+ declare function applyStringSetDelta(target: Y.Map<boolean>, delta: StringSetDelta): void;
1834
+ /**
1835
+ * REPLACE a stringset field with exactly `members`, applied as a per-member
1836
+ * CRDT diff (set members present, delete members no longer present) so a
1837
+ * concurrent client edit merges instead of being clobbered by a
1838
+ * destroy-and-rebuild.
1839
+ */
1840
+ declare function replaceStringSet(rm: Y.Map<any>, fieldName: string, members: Iterable<unknown>): void;
1841
+ /**
1842
+ * Apply the client save-diff stringset change shape (`{additions, removals}`)
1843
+ * to the parent record's Y.Map. Thin wrapper over `applyStringSetDelta` so the
1844
+ * js-bao client and the server write a stringset the exact same way.
1845
+ */
1846
+ declare function applyStringSetChange(recordYMap: Y.Map<any>, fieldName: string, change: {
1847
+ additions: Set<string>;
1848
+ removals: Set<string>;
1849
+ }): void;
1850
+ interface ParsedStringSetOp {
1851
+ add: string[];
1852
+ remove: string[];
1853
+ clear: boolean;
1854
+ }
1855
+ /**
1856
+ * Recognize a `stringset` DELTA op-object inside a write payload:
1857
+ * `{ $add?: string[], $remove?: string[], $clear?: true }`. Returns the
1858
+ * normalized op, or `null` when `value` is not an op-object (a plain array
1859
+ * keeps its REPLACE meaning; a scalar is a scalar). Throws on a malformed op
1860
+ * (unknown `$key`, or `$add`/`$remove` not an array) so an author typo
1861
+ * surfaces instead of silently writing nothing. (#1242, moved here in #1246.)
1862
+ */
1863
+ declare function parseStringSetOp(value: unknown): ParsedStringSetOp | null;
1864
+ /**
1865
+ * Write a single field's value into the record Y.Map according to the field's
1866
+ * declared type. Scalars are `.set()` directly; a `stringset` takes an array
1867
+ * (REPLACE) or a `{ $add/$remove/$clear }` op-object (DELTA). A collection
1868
+ * value in a non-stringset slot, or a scalar in a stringset slot, is rejected
1869
+ * (mirrors #1242 guardrails) rather than silently corrupting the record.
1870
+ *
1871
+ * `declaredType` is the resolved `_meta_` type (may be `undefined` when the doc
1872
+ * has no `_meta_` for the model — first-writer / legacy). An unresolved or
1873
+ * unknown type is treated as scalar for backward compatibility, but a
1874
+ * collection value still requires a real `stringset` type.
1875
+ */
1876
+ declare function writeField(rm: Y.Map<any>, declaredType: string | undefined, key: string, value: unknown, modelName: string): void;
1877
+ declare function registerFunctionDefaultGenerator(name: string, fn: () => any): void;
1878
+ declare function decodeFunctionDefault(name: string): (() => any) | undefined;
1879
+ interface SchemaFieldLike {
1880
+ type?: string;
1881
+ default?: any;
1882
+ autoStamp?: "create" | "update" | "both";
1883
+ required?: boolean;
1884
+ unique?: boolean;
1885
+ }
1886
+ /**
1887
+ * Resolve a field's declared default to a concrete value:
1888
+ * - a function → call it (client path holds real function refs);
1889
+ * - a `"$$literal"` string → an escaped literal; unescape the leading `$$` to
1890
+ * one `$` and use it verbatim (a literal default like `default: "$0.00"`,
1891
+ * which metaSync doubled on encode so it is not mistaken for a function ref);
1892
+ * - a `"$name"` string → decode via the registry and call it (server path,
1893
+ * where `_meta_` `$`-encodes function defaults);
1894
+ * - anything else → the literal value.
1895
+ *
1896
+ * An unknown `"$name"` throws (fail loud) rather than silently leaving the
1897
+ * field unset or writing the literal `"$..."` string into the record — a
1898
+ * missing server-populated field must not hide (#1246, Fork 3).
1899
+ */
1900
+ declare function resolveDefaultValue(rawDefault: any, opts?: {
1901
+ fieldName?: string;
1902
+ modelName?: string;
1903
+ decode?: (name: string) => (() => any) | undefined;
1904
+ }): any;
1905
+ /**
1906
+ * Apply schema `default`s to a plain data object for a CREATE. A field with a
1907
+ * declared default that the caller did not set (absent or `undefined`) is
1908
+ * populated; an explicit caller value always wins. `id` is skipped — both
1909
+ * paths assign the id separately. Pure: mutates and returns `data`.
1910
+ */
1911
+ declare function applyDefaultsFromSchema(fields: Record<string, SchemaFieldLike>, data: Record<string, any>, opts?: {
1912
+ modelName?: string;
1913
+ decodeFunctionDefault?: (name: string) => (() => any) | undefined;
1914
+ }): Record<string, any>;
1915
+ /**
1916
+ * Apply schema `autoStamp` timestamps to a plain data object. Pure transform,
1917
+ * with `now` injected by the caller (server passes `Date.now()`; tests pass a
1918
+ * fixed clock) so the result is deterministic and testable — no global clock.
1919
+ *
1920
+ * Semantics mirror the client `BaseModel.save()` stamp block:
1921
+ * - `'create'` → stamp only when `isNew` and the caller did not set the field
1922
+ * explicitly (a non-null value present in `data`);
1923
+ * - `'update'` / `'both'` → stamp on every save unless the caller set the
1924
+ * field explicitly.
1925
+ *
1926
+ * An explicit caller value (present and non-null in `data`) always wins.
1927
+ * Mutates and returns `data`.
1928
+ */
1929
+ declare function applyAutoStampsFromSchema(fields: Record<string, SchemaFieldLike>, data: Record<string, any>, opts: {
1930
+ isNew: boolean;
1931
+ now: number;
1932
+ }): Record<string, any>;
1933
+
1934
+ export { type AfterQueryContext, type AfterQueryResult, type AggregateRequest, type AggregateResponse, type AggregationOperation, type AggregationOptions, type AggregationResult, type BatchOperation, type BatchOperationResult, type BatchRequest, type BatchResponse, type BeforeDeleteContext, type BeforeQueryContext, type BeforeQueryResult, type BeforeSaveContext, type ConnectDoDbOptions, type CountRequest, type CountResponse, DOClientEngine, type DOClientEngineConfig, type DatabaseDOHooks, type DeleteRequest, type DeleteResponse, type DiscoveredConstraint, type DiscoveredField, type DiscoveredModel, type DiscoveredRelationship, type DiscoveredSchema, type DoDb, type DocumentFilter, type DropIndexRequest, type DropIndexResponse, type DropUniqueConstraintRequest, type DropUniqueConstraintResponse, type ErrorResponse, type FieldType, type FieldWriteKind, type GroupByField, type HookContext, type HookResult, type IncrementRequest, type IncrementResponse, type IndexEntry, type IndexListResponse, type InitJsBaoDOOptions, type InitJsBaoDOResult, type ModelAccessor, type PaginatedResult, type ParsedStringSetOp, type PatchOptions, type PatchRequest, type PatchResponse, type ProjectionSpec, type QueryOptions, type QueryRequest, type QueryResponse, type RegisterIndexRequest, type RegisterIndexResponse, type RegisterUniqueConstraintRequest, type RegisterUniqueConstraintResponse, type SaveOptions, type SaveRequest, type SaveResponse, type SchemaFieldLike, type SortSpec, type StringSetDelta, type StringSetMembership, type StringSetUpdateRequest, type StringSetUpdateResponse, type SyncIndexesRequest, type SyncIndexesResponse, type UniqueConstraintEntry, type UniqueConstraintListResponse, type WriteCondition, applyAutoStampsFromSchema, applyDefaultsFromSchema, applyStringSetChange, applyStringSetDelta, assertNever, buildUniqueKey, clearMetaSyncCache, connectDoDb, decodeFunctionDefault, discoverModelNames, discoverSchema, ensureStringSetYMap, fieldUniqueConstraintName, fieldWriteKind, getActiveDOEngine, inferFieldType, initJsBaoDO, isKnownFieldType, loadSchemaFromTomlString, parseStringSetOp, registerFunctionDefault, registerFunctionDefaultGenerator, replaceStringSet, resetJsBaoDO, resolveDefaultValue, schemaToToml, syncInferredMeta, syncModelMeta, uniqueIndexMapName, writeField };
@@ -563,6 +563,27 @@ declare class BaseModel implements StringSetChangeTracker {
563
563
  */
564
564
  static count(this: new (...args: any[]) => any, filter?: DocumentFilter, options?: Pick<QueryOptions, "documents">): Promise<number>;
565
565
  static findAll<T extends BaseModel>(this: new (...args: any[]) => T): Promise<T[]>;
566
+ /**
567
+ * Resolve a record by its unique-constraint field values via an engine query.
568
+ * Used as a fallback when the `_uniqueIdx_<model>_<constraint>` map has no
569
+ * entry for a key.
570
+ *
571
+ * The index map is a cache over the SQLite projection, not the source of
572
+ * truth: every record is loaded into the projection on `initializeForDocument`
573
+ * regardless of index presence, but a record written by a client predating
574
+ * index maintenance (legacy data) is missing from the index. Such a record
575
+ * silently escapes both `upsertByUnique` lookups and the `save()` uniqueness
576
+ * check. A query over the constraint fields finds it reliably.
577
+ *
578
+ * Contract: RESOLVE ONLY. Returns the owning record (with `_metaDocId` set to
579
+ * the document that holds it, via `queryOne`) or `null`. Callers decide
580
+ * whether to heal the missing index entry (write paths) and do their own
581
+ * logging — this method never mutates the index, so read paths stay
582
+ * read-only. Mirrors `buildUniqueKey`: a null/undefined value in any
583
+ * constraint field means the record does not participate in the constraint,
584
+ * so no query runs.
585
+ */
586
+ private static _resolveByUniqueFallback;
566
587
  static findByUnique<T extends BaseModel>(this: typeof BaseModel & (new (...args: any[]) => T), constraintName: string, value: any | any[]): Promise<T | null>;
567
588
  static upsertByUnique<T extends BaseModel>(this: typeof BaseModel & (new (...args: any[]) => T), constraintName: string, uniqueLookupValue: any | any[], dataToUpsert: Partial<Omit<InstanceType<new (...args: any[]) => T>, keyof BaseModel | "toJSON">> & {
568
589
  id?: string;
@@ -733,6 +754,18 @@ interface HookResult {
733
754
  allow: boolean;
734
755
  /** Reason for denial (returned to client as error message) */
735
756
  reason?: string;
757
+ /**
758
+ * Names of fields the hook wrote onto `ctx.data` that the DO should echo
759
+ * back to the caller in the write response's `appliedFields`. A `beforeSave`
760
+ * hook can compute and set fields on `ctx.data` (e.g. app-level triggers that
761
+ * derive a value from the submitted row); those values are persisted but are
762
+ * invisible to a caller that rebuilds a change event from its original
763
+ * submission inputs. Reporting the names here lets the DO read the final
764
+ * post-hook values back and thread them into `appliedFields` so the caller
765
+ * can propagate them (#1476). The hook contract stays generic — it names the
766
+ * fields it applied and does not need to know how the caller uses them.
767
+ */
768
+ serverAppliedFieldNames?: string[];
736
769
  }
737
770
  /** Result from beforeQuery hooks — can inject additional filters */
738
771
  interface BeforeQueryResult extends HookResult {
@@ -849,6 +882,20 @@ interface QueryResponse {
849
882
  interface SaveResponse {
850
883
  success: boolean;
851
884
  id: string;
885
+ /**
886
+ * Server-applied field values the DO wrote AFTER the caller's submission
887
+ * inputs were assembled, read back from the final post-hook `data`
888
+ * (#1476, consolidating the #1462 `stamps` channel). This is the single
889
+ * "server-applied fields" channel: it carries both the applied `autoStamps`
890
+ * timestamps and any fields a `beforeSave` hook reported via
891
+ * `serverAppliedFieldNames` (e.g. app-level trigger-computed values). Present
892
+ * only when at least one such field ended up with a value. Lets the Worker
893
+ * thread these values into the change-event `data` it broadcasts to
894
+ * subscribers — otherwise a `select`-listed stamped or trigger-computed field
895
+ * never reaches the frame, because the frame is rebuilt from the submission
896
+ * inputs (which predate the DO-side write).
897
+ */
898
+ appliedFields?: Record<string, any>;
852
899
  }
853
900
  interface DeleteResponse {
854
901
  success: boolean;
@@ -931,6 +978,8 @@ interface PatchRequest {
931
978
  interface PatchResponse {
932
979
  success: boolean;
933
980
  id: string;
981
+ /** Server-applied field values (#1476). See `SaveResponse.appliedFields`. */
982
+ appliedFields?: Record<string, any>;
934
983
  }
935
984
  interface BatchOperation {
936
985
  op: "save" | "patch" | "delete" | "increment" | "addToSet" | "removeFromSet";
@@ -958,12 +1007,46 @@ interface BatchOperation {
958
1007
  }
959
1008
  interface BatchRequest {
960
1009
  operations: BatchOperation[];
1010
+ /**
1011
+ * All-or-nothing execution (#1478). When `true`, the FIRST operation that
1012
+ * fails inside the transaction — hook denial, condition / `ifNotExists` /
1013
+ * unique violation, record-not-found, or upsert validation error — aborts
1014
+ * the whole batch: every write is rolled back and the handler returns an
1015
+ * error naming the failed step with its natural HTTP status (see
1016
+ * `BatchOperationResult.status`). When `false` or absent (the default), each
1017
+ * operation's outcome is recorded independently and successful siblings
1018
+ * commit — the partial-success behavior import-bulk and applyToQuery rely on.
1019
+ */
1020
+ atomic?: boolean;
961
1021
  }
1022
+ /**
1023
+ * Stable, machine-readable failure tag on a failed `BatchOperationResult`
1024
+ * (#1478). Derived at each failure site — never inferred from the `error`
1025
+ * string. `#1389` (conditional writes + fencing-token enforcement) surfaces its
1026
+ * condition / stale-token rejections through this same channel.
1027
+ */
1028
+ type BatchFailureCode = "NOT_FOUND" | "HOOK_DENIED" | "CONDITION_NOT_MET" | "ALREADY_EXISTS" | "UNIQUE_VIOLATION" | "VALIDATION_ERROR";
962
1029
  interface BatchOperationResult {
963
1030
  success: boolean;
964
1031
  id: string;
965
1032
  error?: string;
1033
+ /**
1034
+ * Structured failure metadata (#1478), present only on a failed result. Both
1035
+ * are derived at the failure site, not parsed back out of `error`:
1036
+ * - `status` — the natural HTTP status for the cause (404 not found,
1037
+ * 403 hook denial, 409 condition / `ifNotExists` / unique conflict,
1038
+ * 400 upsert validation).
1039
+ * - `code` — a stable machine-readable tag (see `BatchFailureCode`).
1040
+ *
1041
+ * In atomic mode the handler reads the first failure's `status`/`code` to
1042
+ * build the error response, so an increment-on-missing op keeps its 404 and a
1043
+ * hook denial keeps its 403 instead of collapsing to a uniform 409.
1044
+ */
1045
+ status?: number;
1046
+ code?: BatchFailureCode;
966
1047
  values?: Record<string, number>;
1048
+ /** Server-applied field values (#1476). See `SaveResponse.appliedFields`. */
1049
+ appliedFields?: Record<string, any>;
967
1050
  }
968
1051
  interface BatchResponse {
969
1052
  results: BatchOperationResult[];
@@ -1604,6 +1687,41 @@ interface LoadSchemaOptions {
1604
1687
  */
1605
1688
  declare function loadSchemaFromTomlString(tomlString: string, options?: LoadSchemaOptions): DefinedModelSchema[];
1606
1689
 
1690
+ /**
1691
+ * Unique-constraint index helpers (shared CRDT contract).
1692
+ *
1693
+ * js-bao enforces unique constraints inside the Yjs document, not via a SQL
1694
+ * `UNIQUE`. Each constraint has a `_uniqueIdx_<model>_<constraint>` map
1695
+ * (uniqueKey -> surviving recordId) and a document observer that
1696
+ * deterministically discards duplicate losers. Both the client write path and
1697
+ * the server-direct write path (`mutateRecord` in js-bao-wss) must derive the
1698
+ * key and the index map name the SAME way, or a server-written record would
1699
+ * not be seen by the client-side resolver. These helpers are the single source
1700
+ * of truth for that derivation so neither side keeps a parallel copy.
1701
+ */
1702
+
1703
+ /**
1704
+ * Build the unique-constraint key for a record over the given fields.
1705
+ *
1706
+ * Mirrors the document-observer conflict resolver: a `null`/`undefined` value
1707
+ * in any field means the record does not participate in the constraint (returns
1708
+ * `null`); otherwise the per-field `String()` values joined by `"|"`. This is
1709
+ * the key the `_uniqueIdx_*` map is read/written with during conflict
1710
+ * resolution, so it is the key the resolver treats as authoritative.
1711
+ */
1712
+ declare function buildUniqueKey(recordData: Y.Map<any> | Record<string, any>, fields: string[]): string | null;
1713
+ /**
1714
+ * Name of the Yjs map that indexes a unique constraint within a document:
1715
+ * `_uniqueIdx_<model>_<constraint>` mapping uniqueKey -> surviving recordId.
1716
+ */
1717
+ declare function uniqueIndexMapName(modelName: string, constraintName: string): string;
1718
+ /**
1719
+ * The constraint name js-bao assigns to a single-field `unique: true` field
1720
+ * (see `resolveUniqueConstraints` in models/schema.ts). Compound constraints
1721
+ * keep their author-given name instead.
1722
+ */
1723
+ declare function fieldUniqueConstraintName(modelName: string, fieldName: string): string;
1724
+
1607
1725
  /**
1608
1726
  * Meta Sync — writes _meta_* YMaps into a YDoc.
1609
1727
  *
@@ -1656,4 +1774,161 @@ declare function syncModelMeta(yDoc: Y.Doc, modelName: string, schema: ModelSche
1656
1774
  */
1657
1775
  declare function syncInferredMeta(yDoc: Y.Doc, modelName: string, recordData: Record<string, any>): void;
1658
1776
 
1659
- export { type AfterQueryContext, type AfterQueryResult, type AggregateRequest, type AggregateResponse, type AggregationOperation, type AggregationOptions, type AggregationResult, type BatchOperation, type BatchOperationResult, type BatchRequest, type BatchResponse, type BeforeDeleteContext, type BeforeQueryContext, type BeforeQueryResult, type BeforeSaveContext, type ConnectDoDbOptions, type CountRequest, type CountResponse, DOClientEngine, type DOClientEngineConfig, type DatabaseDOHooks, type DeleteRequest, type DeleteResponse, type DiscoveredConstraint, type DiscoveredField, type DiscoveredModel, type DiscoveredRelationship, type DiscoveredSchema, type DoDb, type DocumentFilter, type DropIndexRequest, type DropIndexResponse, type DropUniqueConstraintRequest, type DropUniqueConstraintResponse, type ErrorResponse, type GroupByField, type HookContext, type HookResult, type IncrementRequest, type IncrementResponse, type IndexEntry, type IndexListResponse, type InitJsBaoDOOptions, type InitJsBaoDOResult, type ModelAccessor, type PaginatedResult, type PatchOptions, type PatchRequest, type PatchResponse, type ProjectionSpec, type QueryOptions, type QueryRequest, type QueryResponse, type RegisterIndexRequest, type RegisterIndexResponse, type RegisterUniqueConstraintRequest, type RegisterUniqueConstraintResponse, type SaveOptions, type SaveRequest, type SaveResponse, type SortSpec, type StringSetMembership, type StringSetUpdateRequest, type StringSetUpdateResponse, type SyncIndexesRequest, type SyncIndexesResponse, type UniqueConstraintEntry, type UniqueConstraintListResponse, type WriteCondition, clearMetaSyncCache, connectDoDb, discoverModelNames, discoverSchema, getActiveDOEngine, inferFieldType, initJsBaoDO, loadSchemaFromTomlString, registerFunctionDefault, resetJsBaoDO, schemaToToml, syncInferredMeta, syncModelMeta };
1777
+ /**
1778
+ * Write lifecycle — shared, schema-keyed write behavior for both the js-bao
1779
+ * client (`BaseModel.save()`) and the server-direct workflow write path
1780
+ * (`mutateRecord` in js-bao-wss `src/yjs-room-v2.ts`).
1781
+ *
1782
+ * Background (#1246): the server re-implemented js-bao's client write behavior,
1783
+ * so the two paths could drift and new js-bao field types were not picked up by
1784
+ * workflow writes automatically. This module is the single source of truth for
1785
+ * the *pure transform* (data + schema → Yjs structure), keyed only off schema
1786
+ * metadata — no ORM concerns (reactivity, local-change tracking, document
1787
+ * connection). Both paths call these helpers, so:
1788
+ *
1789
+ * 1. the stringset Yjs-shape primitives are written the same way on both sides;
1790
+ * 2. schema `default`s and `autoStamp`s are applied the same way on both sides;
1791
+ * 3. a new `FieldType` breaks `fieldWriteKind`'s exhaustive switch until it is
1792
+ * classified once — and the server, calling the same core, inherits it.
1793
+ *
1794
+ * The unique-index contract already lives in `../utils/uniqueIndex.ts`; this
1795
+ * module is the same "neither side keeps a parallel copy" pattern generalized
1796
+ * to the rest of the write lifecycle.
1797
+ */
1798
+
1799
+ /**
1800
+ * Compile-time exhaustiveness guard. Passing a value whose type is not `never`
1801
+ * is a TypeScript error, so an unhandled `case` in an exhaustive `switch` fails
1802
+ * `tsc`. At runtime (reached only for a genuinely unknown value) it throws.
1803
+ */
1804
+ declare function assertNever(x: never): never;
1805
+ /**
1806
+ * How a field's declared type is written into the record Y.Map. Every member
1807
+ * of the `FieldType` union must be classified here; adding a new field type to
1808
+ * the union without a `case` below fails `pnpm typecheck` (via `assertNever`),
1809
+ * which is what forces both write paths to handle it. (#1246, layer 2.)
1810
+ */
1811
+ type FieldWriteKind = "scalar" | "stringset";
1812
+ declare function fieldWriteKind(type: FieldType): FieldWriteKind;
1813
+ /** True if `type` is a member of the `FieldType` union. */
1814
+ declare function isKnownFieldType(type: string): type is FieldType;
1815
+ /**
1816
+ * Resolve (or migrate to) the canonical nested `Y.Map<boolean>` for a
1817
+ * `stringset` field. A legacy plain-object value is carried over member by
1818
+ * member; the nested map is never destroyed and rebuilt, so a concurrent
1819
+ * client `.add()`/`.delete()` merges instead of being clobbered.
1820
+ */
1821
+ declare function ensureStringSetYMap(rm: Y.Map<any>, fieldName: string): Y.Map<boolean>;
1822
+ interface StringSetDelta {
1823
+ add?: Iterable<string>;
1824
+ remove?: Iterable<string>;
1825
+ clear?: boolean;
1826
+ }
1827
+ /**
1828
+ * Apply a per-member DELTA to a stringset target map. `clear` empties the set
1829
+ * first; then `remove` deletes, then `add` sets — so an `add` wins over a
1830
+ * `remove` of the same member within one delta. Members not named are left
1831
+ * untouched, so two concurrent `add`s of different members merge cleanly.
1832
+ */
1833
+ declare function applyStringSetDelta(target: Y.Map<boolean>, delta: StringSetDelta): void;
1834
+ /**
1835
+ * REPLACE a stringset field with exactly `members`, applied as a per-member
1836
+ * CRDT diff (set members present, delete members no longer present) so a
1837
+ * concurrent client edit merges instead of being clobbered by a
1838
+ * destroy-and-rebuild.
1839
+ */
1840
+ declare function replaceStringSet(rm: Y.Map<any>, fieldName: string, members: Iterable<unknown>): void;
1841
+ /**
1842
+ * Apply the client save-diff stringset change shape (`{additions, removals}`)
1843
+ * to the parent record's Y.Map. Thin wrapper over `applyStringSetDelta` so the
1844
+ * js-bao client and the server write a stringset the exact same way.
1845
+ */
1846
+ declare function applyStringSetChange(recordYMap: Y.Map<any>, fieldName: string, change: {
1847
+ additions: Set<string>;
1848
+ removals: Set<string>;
1849
+ }): void;
1850
+ interface ParsedStringSetOp {
1851
+ add: string[];
1852
+ remove: string[];
1853
+ clear: boolean;
1854
+ }
1855
+ /**
1856
+ * Recognize a `stringset` DELTA op-object inside a write payload:
1857
+ * `{ $add?: string[], $remove?: string[], $clear?: true }`. Returns the
1858
+ * normalized op, or `null` when `value` is not an op-object (a plain array
1859
+ * keeps its REPLACE meaning; a scalar is a scalar). Throws on a malformed op
1860
+ * (unknown `$key`, or `$add`/`$remove` not an array) so an author typo
1861
+ * surfaces instead of silently writing nothing. (#1242, moved here in #1246.)
1862
+ */
1863
+ declare function parseStringSetOp(value: unknown): ParsedStringSetOp | null;
1864
+ /**
1865
+ * Write a single field's value into the record Y.Map according to the field's
1866
+ * declared type. Scalars are `.set()` directly; a `stringset` takes an array
1867
+ * (REPLACE) or a `{ $add/$remove/$clear }` op-object (DELTA). A collection
1868
+ * value in a non-stringset slot, or a scalar in a stringset slot, is rejected
1869
+ * (mirrors #1242 guardrails) rather than silently corrupting the record.
1870
+ *
1871
+ * `declaredType` is the resolved `_meta_` type (may be `undefined` when the doc
1872
+ * has no `_meta_` for the model — first-writer / legacy). An unresolved or
1873
+ * unknown type is treated as scalar for backward compatibility, but a
1874
+ * collection value still requires a real `stringset` type.
1875
+ */
1876
+ declare function writeField(rm: Y.Map<any>, declaredType: string | undefined, key: string, value: unknown, modelName: string): void;
1877
+ declare function registerFunctionDefaultGenerator(name: string, fn: () => any): void;
1878
+ declare function decodeFunctionDefault(name: string): (() => any) | undefined;
1879
+ interface SchemaFieldLike {
1880
+ type?: string;
1881
+ default?: any;
1882
+ autoStamp?: "create" | "update" | "both";
1883
+ required?: boolean;
1884
+ unique?: boolean;
1885
+ }
1886
+ /**
1887
+ * Resolve a field's declared default to a concrete value:
1888
+ * - a function → call it (client path holds real function refs);
1889
+ * - a `"$$literal"` string → an escaped literal; unescape the leading `$$` to
1890
+ * one `$` and use it verbatim (a literal default like `default: "$0.00"`,
1891
+ * which metaSync doubled on encode so it is not mistaken for a function ref);
1892
+ * - a `"$name"` string → decode via the registry and call it (server path,
1893
+ * where `_meta_` `$`-encodes function defaults);
1894
+ * - anything else → the literal value.
1895
+ *
1896
+ * An unknown `"$name"` throws (fail loud) rather than silently leaving the
1897
+ * field unset or writing the literal `"$..."` string into the record — a
1898
+ * missing server-populated field must not hide (#1246, Fork 3).
1899
+ */
1900
+ declare function resolveDefaultValue(rawDefault: any, opts?: {
1901
+ fieldName?: string;
1902
+ modelName?: string;
1903
+ decode?: (name: string) => (() => any) | undefined;
1904
+ }): any;
1905
+ /**
1906
+ * Apply schema `default`s to a plain data object for a CREATE. A field with a
1907
+ * declared default that the caller did not set (absent or `undefined`) is
1908
+ * populated; an explicit caller value always wins. `id` is skipped — both
1909
+ * paths assign the id separately. Pure: mutates and returns `data`.
1910
+ */
1911
+ declare function applyDefaultsFromSchema(fields: Record<string, SchemaFieldLike>, data: Record<string, any>, opts?: {
1912
+ modelName?: string;
1913
+ decodeFunctionDefault?: (name: string) => (() => any) | undefined;
1914
+ }): Record<string, any>;
1915
+ /**
1916
+ * Apply schema `autoStamp` timestamps to a plain data object. Pure transform,
1917
+ * with `now` injected by the caller (server passes `Date.now()`; tests pass a
1918
+ * fixed clock) so the result is deterministic and testable — no global clock.
1919
+ *
1920
+ * Semantics mirror the client `BaseModel.save()` stamp block:
1921
+ * - `'create'` → stamp only when `isNew` and the caller did not set the field
1922
+ * explicitly (a non-null value present in `data`);
1923
+ * - `'update'` / `'both'` → stamp on every save unless the caller set the
1924
+ * field explicitly.
1925
+ *
1926
+ * An explicit caller value (present and non-null in `data`) always wins.
1927
+ * Mutates and returns `data`.
1928
+ */
1929
+ declare function applyAutoStampsFromSchema(fields: Record<string, SchemaFieldLike>, data: Record<string, any>, opts: {
1930
+ isNew: boolean;
1931
+ now: number;
1932
+ }): Record<string, any>;
1933
+
1934
+ export { type AfterQueryContext, type AfterQueryResult, type AggregateRequest, type AggregateResponse, type AggregationOperation, type AggregationOptions, type AggregationResult, type BatchOperation, type BatchOperationResult, type BatchRequest, type BatchResponse, type BeforeDeleteContext, type BeforeQueryContext, type BeforeQueryResult, type BeforeSaveContext, type ConnectDoDbOptions, type CountRequest, type CountResponse, DOClientEngine, type DOClientEngineConfig, type DatabaseDOHooks, type DeleteRequest, type DeleteResponse, type DiscoveredConstraint, type DiscoveredField, type DiscoveredModel, type DiscoveredRelationship, type DiscoveredSchema, type DoDb, type DocumentFilter, type DropIndexRequest, type DropIndexResponse, type DropUniqueConstraintRequest, type DropUniqueConstraintResponse, type ErrorResponse, type FieldType, type FieldWriteKind, type GroupByField, type HookContext, type HookResult, type IncrementRequest, type IncrementResponse, type IndexEntry, type IndexListResponse, type InitJsBaoDOOptions, type InitJsBaoDOResult, type ModelAccessor, type PaginatedResult, type ParsedStringSetOp, type PatchOptions, type PatchRequest, type PatchResponse, type ProjectionSpec, type QueryOptions, type QueryRequest, type QueryResponse, type RegisterIndexRequest, type RegisterIndexResponse, type RegisterUniqueConstraintRequest, type RegisterUniqueConstraintResponse, type SaveOptions, type SaveRequest, type SaveResponse, type SchemaFieldLike, type SortSpec, type StringSetDelta, type StringSetMembership, type StringSetUpdateRequest, type StringSetUpdateResponse, type SyncIndexesRequest, type SyncIndexesResponse, type UniqueConstraintEntry, type UniqueConstraintListResponse, type WriteCondition, applyAutoStampsFromSchema, applyDefaultsFromSchema, applyStringSetChange, applyStringSetDelta, assertNever, buildUniqueKey, clearMetaSyncCache, connectDoDb, decodeFunctionDefault, discoverModelNames, discoverSchema, ensureStringSetYMap, fieldUniqueConstraintName, fieldWriteKind, getActiveDOEngine, inferFieldType, initJsBaoDO, isKnownFieldType, loadSchemaFromTomlString, parseStringSetOp, registerFunctionDefault, registerFunctionDefaultGenerator, replaceStringSet, resetJsBaoDO, resolveDefaultValue, schemaToToml, syncInferredMeta, syncModelMeta, uniqueIndexMapName, writeField };