@hodeinavarro/odoo-rpc-ts 0.2.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 (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +401 -0
  3. package/dist/client.d.ts +184 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +277 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/combinators/retryOnSessionExpired.d.ts +13 -0
  8. package/dist/combinators/retryOnSessionExpired.d.ts.map +1 -0
  9. package/dist/combinators/retryOnSessionExpired.js +15 -0
  10. package/dist/combinators/retryOnSessionExpired.js.map +1 -0
  11. package/dist/commands.d.ts +91 -0
  12. package/dist/commands.d.ts.map +1 -0
  13. package/dist/commands.js +69 -0
  14. package/dist/commands.js.map +1 -0
  15. package/dist/config.d.ts +32 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +60 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/domain.d.ts +30 -0
  20. package/dist/domain.d.ts.map +1 -0
  21. package/dist/domain.js +70 -0
  22. package/dist/domain.js.map +1 -0
  23. package/dist/errors/auth.d.ts +27 -0
  24. package/dist/errors/auth.d.ts.map +1 -0
  25. package/dist/errors/auth.js +8 -0
  26. package/dist/errors/auth.js.map +1 -0
  27. package/dist/errors/index.d.ts +30 -0
  28. package/dist/errors/index.d.ts.map +1 -0
  29. package/dist/errors/index.js +13 -0
  30. package/dist/errors/index.js.map +1 -0
  31. package/dist/errors/mapFault.d.ts +31 -0
  32. package/dist/errors/mapFault.d.ts.map +1 -0
  33. package/dist/errors/mapFault.js +48 -0
  34. package/dist/errors/mapFault.js.map +1 -0
  35. package/dist/errors/protocol.d.ts +22 -0
  36. package/dist/errors/protocol.d.ts.map +1 -0
  37. package/dist/errors/protocol.js +9 -0
  38. package/dist/errors/protocol.js.map +1 -0
  39. package/dist/errors/schema.d.ts +22 -0
  40. package/dist/errors/schema.d.ts.map +1 -0
  41. package/dist/errors/schema.js +14 -0
  42. package/dist/errors/schema.js.map +1 -0
  43. package/dist/errors/server.d.ts +109 -0
  44. package/dist/errors/server.d.ts.map +1 -0
  45. package/dist/errors/server.js +49 -0
  46. package/dist/errors/server.js.map +1 -0
  47. package/dist/errors/session.d.ts +16 -0
  48. package/dist/errors/session.d.ts.map +1 -0
  49. package/dist/errors/session.js +11 -0
  50. package/dist/errors/session.js.map +1 -0
  51. package/dist/errors/transport.d.ts +42 -0
  52. package/dist/errors/transport.d.ts.map +1 -0
  53. package/dist/errors/transport.js +49 -0
  54. package/dist/errors/transport.js.map +1 -0
  55. package/dist/index.d.ts +33 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +57 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/internal/platform.d.ts +12 -0
  60. package/dist/internal/platform.d.ts.map +1 -0
  61. package/dist/internal/platform.js +11 -0
  62. package/dist/internal/platform.js.map +1 -0
  63. package/dist/internal/singleFlight.d.ts +29 -0
  64. package/dist/internal/singleFlight.d.ts.map +1 -0
  65. package/dist/internal/singleFlight.js +41 -0
  66. package/dist/internal/singleFlight.js.map +1 -0
  67. package/dist/profiles.d.ts +179 -0
  68. package/dist/profiles.d.ts.map +1 -0
  69. package/dist/profiles.js +191 -0
  70. package/dist/profiles.js.map +1 -0
  71. package/dist/protocol/jsonrpc.d.ts +127 -0
  72. package/dist/protocol/jsonrpc.d.ts.map +1 -0
  73. package/dist/protocol/jsonrpc.js +117 -0
  74. package/dist/protocol/jsonrpc.js.map +1 -0
  75. package/dist/records/index.d.ts +14 -0
  76. package/dist/records/index.d.ts.map +1 -0
  77. package/dist/records/index.js +14 -0
  78. package/dist/records/index.js.map +1 -0
  79. package/dist/records/recordModel.d.ts +131 -0
  80. package/dist/records/recordModel.d.ts.map +1 -0
  81. package/dist/records/recordModel.js +101 -0
  82. package/dist/records/recordModel.js.map +1 -0
  83. package/dist/records/related.d.ts +42 -0
  84. package/dist/records/related.d.ts.map +1 -0
  85. package/dist/records/related.js +51 -0
  86. package/dist/records/related.js.map +1 -0
  87. package/dist/records/relations.d.ts +66 -0
  88. package/dist/records/relations.d.ts.map +1 -0
  89. package/dist/records/relations.js +119 -0
  90. package/dist/records/relations.js.map +1 -0
  91. package/dist/records/spec.d.ts +44 -0
  92. package/dist/records/spec.d.ts.map +1 -0
  93. package/dist/records/spec.js +63 -0
  94. package/dist/records/spec.js.map +1 -0
  95. package/dist/records/typed.d.ts +71 -0
  96. package/dist/records/typed.d.ts.map +1 -0
  97. package/dist/records/typed.js +65 -0
  98. package/dist/records/typed.js.map +1 -0
  99. package/dist/rpc.d.ts +90 -0
  100. package/dist/rpc.d.ts.map +1 -0
  101. package/dist/rpc.js +113 -0
  102. package/dist/rpc.js.map +1 -0
  103. package/dist/services/db.d.ts +128 -0
  104. package/dist/services/db.d.ts.map +1 -0
  105. package/dist/services/db.js +132 -0
  106. package/dist/services/db.js.map +1 -0
  107. package/dist/services/report.d.ts +48 -0
  108. package/dist/services/report.d.ts.map +1 -0
  109. package/dist/services/report.js +103 -0
  110. package/dist/services/report.js.map +1 -0
  111. package/dist/session/cookie.d.ts +114 -0
  112. package/dist/session/cookie.d.ts.map +1 -0
  113. package/dist/session/cookie.js +231 -0
  114. package/dist/session/cookie.js.map +1 -0
  115. package/dist/testing/fakeTransport.d.ts +40 -0
  116. package/dist/testing/fakeTransport.d.ts.map +1 -0
  117. package/dist/testing/fakeTransport.js +36 -0
  118. package/dist/testing/fakeTransport.js.map +1 -0
  119. package/dist/testing/index.d.ts +9 -0
  120. package/dist/testing/index.d.ts.map +1 -0
  121. package/dist/testing/index.js +9 -0
  122. package/dist/testing/index.js.map +1 -0
  123. package/dist/transport.d.ts +62 -0
  124. package/dist/transport.d.ts.map +1 -0
  125. package/dist/transport.js +9 -0
  126. package/dist/transport.js.map +1 -0
  127. package/dist/transports/json2.d.ts +41 -0
  128. package/dist/transports/json2.d.ts.map +1 -0
  129. package/dist/transports/json2.js +196 -0
  130. package/dist/transports/json2.js.map +1 -0
  131. package/dist/transports/jsonrpc.d.ts +41 -0
  132. package/dist/transports/jsonrpc.d.ts.map +1 -0
  133. package/dist/transports/jsonrpc.js +119 -0
  134. package/dist/transports/jsonrpc.js.map +1 -0
  135. package/dist/transports/web.d.ts +15 -0
  136. package/dist/transports/web.d.ts.map +1 -0
  137. package/dist/transports/web.js +62 -0
  138. package/dist/transports/web.js.map +1 -0
  139. package/dist/version-live.d.ts +41 -0
  140. package/dist/version-live.d.ts.map +1 -0
  141. package/dist/version-live.js +26 -0
  142. package/dist/version-live.js.map +1 -0
  143. package/dist/version.d.ts +78 -0
  144. package/dist/version.d.ts.map +1 -0
  145. package/dist/version.js +46 -0
  146. package/dist/version.js.map +1 -0
  147. package/docs/protocol-verification.md +43 -0
  148. package/package.json +71 -0
  149. package/src/client.ts +731 -0
  150. package/src/combinators/retryOnSessionExpired.ts +25 -0
  151. package/src/commands.ts +112 -0
  152. package/src/config.ts +110 -0
  153. package/src/domain.ts +91 -0
  154. package/src/errors/auth.ts +28 -0
  155. package/src/errors/index.ts +50 -0
  156. package/src/errors/mapFault.ts +92 -0
  157. package/src/errors/protocol.ts +19 -0
  158. package/src/errors/schema.ts +18 -0
  159. package/src/errors/server.ts +79 -0
  160. package/src/errors/session.ts +12 -0
  161. package/src/errors/transport.ts +74 -0
  162. package/src/index.ts +152 -0
  163. package/src/internal/platform.ts +22 -0
  164. package/src/internal/singleFlight.ts +80 -0
  165. package/src/profiles.ts +386 -0
  166. package/src/protocol/jsonrpc.ts +162 -0
  167. package/src/records/index.ts +49 -0
  168. package/src/records/recordModel.ts +211 -0
  169. package/src/records/related.ts +80 -0
  170. package/src/records/relations.ts +192 -0
  171. package/src/records/spec.ts +96 -0
  172. package/src/records/typed.ts +180 -0
  173. package/src/rpc.ts +219 -0
  174. package/src/services/db.ts +282 -0
  175. package/src/services/report.ts +165 -0
  176. package/src/session/cookie.ts +461 -0
  177. package/src/testing/fakeTransport.ts +84 -0
  178. package/src/testing/index.ts +9 -0
  179. package/src/transport.ts +72 -0
  180. package/src/transports/json2.ts +303 -0
  181. package/src/transports/jsonrpc.ts +218 -0
  182. package/src/transports/web.ts +96 -0
  183. package/src/version-live.ts +67 -0
  184. package/src/version.ts +102 -0
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Declared record models — the SPEC tier of the records design ("declared
3
+ * prefetch" over the 17+ `specification` protocol; the classic tier in
4
+ * `typed.ts`/`related.ts` speaks the 16+ `[id, name]`-pair protocol instead).
5
+ * A `RecordSpec`
6
+ * declares a model's field graph with `effect/Schema`: scalars decode directly,
7
+ * relations declared with {@link Many2One}/{@link One2Many} carry a nested child
8
+ * `RecordSpec`. From one declaration we derive BOTH:
9
+ *
10
+ * 1. a row {@link RecordSpec.schema} that strict-decodes the NESTED wire shape
11
+ * the 17+ `web_search_read`/`web_read` `specification` protocol returns
12
+ * (a many2one as a `{ id, ...declared }` dict or `false`; an x2many as a
13
+ * nested list), and
14
+ * 2. the field {@link RecordSpec.fields} metadata that {@link ./spec.ts} compiles
15
+ * into that `specification` payload.
16
+ *
17
+ * No I/O lives here — this is pure declaration. The single round trip that turns
18
+ * a spec into rows is in `OdooClient.searchTyped`/`readTyped`/`saveTyped`.
19
+ *
20
+ * MANY2ONE VALUE SHAPE (decision, records design 2026-07-10). A declared
21
+ * many2one decodes to a FLAT struct `{ id, ...declaredChildFields } | null`, not
22
+ * a `{ id, value }` wrapper. In TS, `| null` already carries "is it set?", and a
23
+ * flat struct exposes `.id` and every declared child field as directly-typed
24
+ * properties (`company.name`) with no `__getattr__`-style proxy and no extra
25
+ * `.value` hop — strictly better inference and ergonomics than a wrapper. This
26
+ * deliberately diverges from the Python design's `.value` recommendation, which
27
+ * existed to model presence separately from the record; TS does not need it.
28
+ */
29
+ import { Schema, SchemaTransformation } from "effect";
30
+ import type { HasId } from "./typed.ts";
31
+
32
+ /**
33
+ * Per-field compilation metadata: how {@link ./spec.ts} renders each field. The
34
+ * relation `child` is typed loosely (`RecordSpec<any, any>`) because `Schema` is
35
+ * invariant in its type parameter — a precisely-typed child spec is not
36
+ * assignable to a widened one, so the metadata graph is deliberately erased. The
37
+ * precise types live on {@link RecordSpec.schema} for decode; this metadata is
38
+ * only ever walked structurally by the pure compiler.
39
+ */
40
+ export type FieldMeta =
41
+ | { readonly kind: "scalar" }
42
+ // oxlint-disable-next-line no-explicit-any -- Schema is invariant; see above.
43
+ | { readonly kind: "many2one"; readonly child: RecordSpec<any, any> }
44
+ | {
45
+ readonly kind: "one2many";
46
+ // oxlint-disable-next-line no-explicit-any -- Schema is invariant; see above.
47
+ readonly child: RecordSpec<any, any>;
48
+ readonly limit: number | undefined;
49
+ };
50
+
51
+ /**
52
+ * A declared record model: its Odoo model name, the row decode `schema` (the
53
+ * whole nested graph), the per-field compilation `fields`, and whether it
54
+ * declares any relation (the version gate keys on this). Build one with
55
+ * {@link defineRecord}.
56
+ */
57
+ export interface RecordSpec<A extends HasId, I = A> {
58
+ readonly model: string;
59
+ readonly schema: Schema.Codec<A, I>;
60
+ readonly fields: Readonly<Record<string, FieldMeta>>;
61
+ /** `true` iff at least one declared field is a many2one/one2many relation. */
62
+ readonly hasRelations: boolean;
63
+ }
64
+
65
+ /** A declared many2one relation field — nests a child {@link RecordSpec}. */
66
+ export interface Many2OneDecl<A extends HasId, I> {
67
+ readonly _decl: "many2one";
68
+ readonly spec: RecordSpec<A, I>;
69
+ }
70
+
71
+ /** A declared one2many/many2many relation field — nests a child {@link RecordSpec}. */
72
+ export interface One2ManyDecl<A extends HasId, I> {
73
+ readonly _decl: "one2many";
74
+ readonly spec: RecordSpec<A, I>;
75
+ readonly limit: number | undefined;
76
+ }
77
+
78
+ /**
79
+ * Declare a many2one field: `company_id: Many2One(Company)`. Decodes the nested
80
+ * `{ id, ...Company fields }` wire dict to `Company | null` (Odoo's `false`
81
+ * empty → `null`). Note it does NOT accept the classic `[id, name]` pair — under
82
+ * a `specification` the server returns a dict, so a leaked pair is drift, not a
83
+ * silent decode.
84
+ */
85
+ export const Many2One = <A extends HasId, I>(spec: RecordSpec<A, I>): Many2OneDecl<A, I> => ({
86
+ _decl: "many2one",
87
+ spec,
88
+ });
89
+
90
+ /**
91
+ * Declare an x2many field: `child_ids: One2Many(Contact)` or
92
+ * `One2Many(Contact, { limit: 20 })`. Decodes the nested list to
93
+ * `ReadonlyArray<Contact>`; `limit`, when given, rides the compiled
94
+ * `specification` so the server bounds the co-recordset.
95
+ */
96
+ export const One2Many = <A extends HasId, I>(
97
+ spec: RecordSpec<A, I>,
98
+ options?: { readonly limit?: number },
99
+ ): One2ManyDecl<A, I> => ({ _decl: "one2many", spec, limit: options?.limit });
100
+
101
+ /**
102
+ * A field of a {@link defineRecord} declaration: a scalar schema or a relation.
103
+ * The relation members are `any`-parameterized because `Schema` (inside the
104
+ * nested child spec) is invariant — a precisely-typed `Many2One(Company)` is not
105
+ * assignable to a widened `Many2OneDecl<HasId, unknown>`. Field-level types are
106
+ * recovered structurally by {@link FieldType}/{@link FieldEncoded} via `infer`.
107
+ */
108
+ export type FieldInput =
109
+ | Schema.Top
110
+ // oxlint-disable-next-line no-explicit-any -- Schema invariance; see above.
111
+ | Many2OneDecl<any, any>
112
+ // oxlint-disable-next-line no-explicit-any -- Schema invariance; see above.
113
+ | One2ManyDecl<any, any>;
114
+
115
+ /** The decoded (`Type`) value a declared field yields. */
116
+ type FieldType<T> = T extends Many2OneDecl<infer A, infer _I>
117
+ ? A | null
118
+ : T extends One2ManyDecl<infer A, infer _I>
119
+ ? ReadonlyArray<A>
120
+ : T extends Schema.Top
121
+ ? T["Type"]
122
+ : never;
123
+
124
+ /** The wire (`Encoded`) value a declared field maps from. */
125
+ type FieldEncoded<T> = T extends Many2OneDecl<infer _A, infer I>
126
+ ? I | false
127
+ : T extends One2ManyDecl<infer _A, infer I>
128
+ ? ReadonlyArray<I>
129
+ : T extends Schema.Top
130
+ ? T["Encoded"]
131
+ : never;
132
+
133
+ /** The decoded row type of a declaration `F` — always carries `id: number`. */
134
+ export type RowType<F extends Record<string, FieldInput>> = {
135
+ readonly id: number;
136
+ } & { readonly [K in keyof F]: FieldType<F[K]> };
137
+
138
+ /** The wire row type of a declaration `F`. */
139
+ export type RowEncoded<F extends Record<string, FieldInput>> = {
140
+ readonly id: number;
141
+ } & { readonly [K in keyof F]: FieldEncoded<F[K]> };
142
+
143
+ const isRelationDecl = (
144
+ value: FieldInput,
145
+ // oxlint-disable-next-line no-explicit-any -- Schema invariance; see FieldInput.
146
+ ): value is Many2OneDecl<any, any> | One2ManyDecl<any, any> =>
147
+ typeof value === "object" && value !== null && "_decl" in value;
148
+
149
+ /**
150
+ * The nullable-many2one nested schema: the child dict, or Odoo's `false` empty
151
+ * → `null`. Strict: a classic `[id, name]` pair (or any non-dict) fails decode,
152
+ * surfacing as {@link SchemaDriftError} at the client boundary rather than being
153
+ * cast past.
154
+ */
155
+ // oxlint-disable-next-line no-explicit-any -- Schema invariance; see FieldInput.
156
+ const many2oneFieldSchema = (child: RecordSpec<any, any>): Schema.Top =>
157
+ Schema.Union([child.schema, Schema.Literal(false)]).pipe(
158
+ Schema.decodeTo(
159
+ Schema.NullOr(Schema.toType(child.schema)),
160
+ SchemaTransformation.transform({
161
+ decode: (wire: unknown) => (wire === false ? null : wire),
162
+ encode: (value: unknown) => (value === null ? (false as const) : value),
163
+ }),
164
+ ),
165
+ );
166
+
167
+ /**
168
+ * Build a {@link RecordSpec} from a model name and a field declaration. `id`
169
+ * (`Schema.Number`) is added automatically and always compiled into the
170
+ * `specification`, so every declared record and every relation subtree carries
171
+ * its id — the key downstream joins and re-reads rely on.
172
+ *
173
+ * ```ts
174
+ * const Company = defineRecord("res.company", { name: Schema.String });
175
+ * const Partner = defineRecord("res.partner", {
176
+ * name: Schema.String,
177
+ * company_id: Many2One(Company),
178
+ * child_ids: One2Many(Partner_contact, { limit: 20 }),
179
+ * });
180
+ * ```
181
+ */
182
+ export const defineRecord = <F extends Record<string, FieldInput>>(
183
+ model: string,
184
+ fields: F,
185
+ ): RecordSpec<RowType<F>, RowEncoded<F>> => {
186
+ const structFields: Record<string, Schema.Top> = { id: Schema.Number };
187
+ const meta: Record<string, FieldMeta> = { id: { kind: "scalar" } };
188
+
189
+ for (const [name, decl] of Object.entries(fields)) {
190
+ if (isRelationDecl(decl)) {
191
+ if (decl._decl === "many2one") {
192
+ structFields[name] = many2oneFieldSchema(decl.spec);
193
+ meta[name] = { kind: "many2one", child: decl.spec };
194
+ } else {
195
+ structFields[name] = Schema.Array(decl.spec.schema);
196
+ meta[name] = { kind: "one2many", child: decl.spec, limit: decl.limit };
197
+ }
198
+ } else {
199
+ structFields[name] = decl;
200
+ meta[name] = { kind: "scalar" };
201
+ }
202
+ }
203
+
204
+ // The dynamic Struct build cannot preserve the precise mapped type; we recover
205
+ // it via the RowType/RowEncoded projection, which is exactly what the loop
206
+ // constructs field-for-field.
207
+ const schema = Schema.Struct(structFields) as unknown as Schema.Codec<RowType<F>, RowEncoded<F>>;
208
+ const hasRelations = Object.values(meta).some((m) => m.kind !== "scalar");
209
+
210
+ return { model, schema, fields: meta, hasRelations };
211
+ };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Pure join helpers for explicit relation traversal (the classic tier). No I/O: given
3
+ * already-decoded rows and a field accessor, collect the distinct related ids to
4
+ * fetch, and key decoded related rows by id for O(1) joins. The batched `read`
5
+ * itself lives in {@link ./typed.ts}; everything here is inert data shaping.
6
+ */
7
+ import type { Many2OneRefValue } from "./relations.ts";
8
+
9
+ /** A many2one field's decoded value: a ref, a bare id, or empty. */
10
+ export type RefOrId = Many2OneRefValue | number | null | undefined;
11
+
12
+ /**
13
+ * The related id a {@link RefOrId} points at, or `null` when empty. A ref value
14
+ * carries `.id`; a bare number is itself the id. Anything else is out of
15
+ * contract and yields `null` (the caller's runtime guard rejects true misuse
16
+ * before this is reached — see {@link ./typed.ts}).
17
+ */
18
+ export const refId = (ref: RefOrId): number | null => {
19
+ if (ref === null || ref === undefined) {
20
+ return null;
21
+ }
22
+ if (typeof ref === "number") {
23
+ return ref;
24
+ }
25
+ return typeof ref === "object" && typeof ref.id === "number" ? ref.id : null;
26
+ };
27
+
28
+ /**
29
+ * The distinct, non-null related ids reachable through `accessor` across all
30
+ * `rows`, in first-seen order. This is the exact id set a single batched `read`
31
+ * must cover — an empty result means ZERO round trips are needed.
32
+ */
33
+ export const collectRefIds = <Row>(
34
+ rows: ReadonlyArray<Row>,
35
+ accessor: (row: Row) => RefOrId,
36
+ ): ReadonlyArray<number> => {
37
+ const seen = new Set<number>();
38
+ const out: number[] = [];
39
+ for (const row of rows) {
40
+ const id = refId(accessor(row));
41
+ if (id !== null && !seen.has(id)) {
42
+ seen.add(id);
43
+ out.push(id);
44
+ }
45
+ }
46
+ return out;
47
+ };
48
+
49
+ /**
50
+ * A snapshot of decoded related rows keyed by id. Inert: it holds data and never
51
+ * fetches. `get` accepts a many2one ref, a bare id, or `null`/`undefined` and
52
+ * returns the joined row or `null` — a pure lookup, never a round trip.
53
+ */
54
+ export interface RelatedMap<A> {
55
+ /** Join a many2one value (ref/id/empty) to its decoded related row, or `null`. */
56
+ readonly get: (ref: RefOrId) => A | null;
57
+ /** The number of distinct related rows held. */
58
+ readonly size: number;
59
+ }
60
+
61
+ /**
62
+ * Build a {@link RelatedMap} from decoded related rows, keyed by each row's
63
+ * `id`. The rows come straight from a single batched `read`, so a duplicate id
64
+ * cannot occur; last-write-wins is a harmless invariant, not a policy.
65
+ */
66
+ export const makeRelatedMap = <A extends { readonly id: number }>(
67
+ rows: ReadonlyArray<A>,
68
+ ): RelatedMap<A> => {
69
+ const byId = new Map<number, A>();
70
+ for (const row of rows) {
71
+ byId.set(row.id, row);
72
+ }
73
+ return {
74
+ get: (ref) => {
75
+ const id = refId(ref);
76
+ return id === null ? null : (byId.get(id) ?? null);
77
+ },
78
+ size: byId.size,
79
+ };
80
+ };
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Pure relation & temporal value schemas — the decode vocabulary for typed
3
+ * records. No I/O: these are `effect/Schema` definitions a caller composes into
4
+ * a row schema. They transform Odoo's on-the-wire shapes (classic `[id, name]`
5
+ * many2one pairs, `false` empties, naive datetime strings) into ergonomic,
6
+ * fully-typed domain values, failing loudly as schema drift on anything else.
7
+ */
8
+ import { Effect, Option, Schema, SchemaIssue, SchemaParser, SchemaTransformation } from "effect";
9
+
10
+ // --- many2one references ----------------------------------------------------
11
+
12
+ /**
13
+ * A decoded many2one reference: the related record's id and its display label,
14
+ * as Odoo carries them together in a classic `search_read`/`read` payload. The
15
+ * label is already present, so the common "show the name" case needs ZERO extra
16
+ * round trips — see {@link ../typed.ts | TypedRecordSet.fetchRelated} for when
17
+ * you need more than the label.
18
+ */
19
+ export interface Many2OneRefValue {
20
+ readonly id: number;
21
+ readonly name: string;
22
+ }
23
+
24
+ /** The decoded-value schema for a {@link Many2OneRefValue}. */
25
+ export const Many2OneRefValue: Schema.Codec<Many2OneRefValue> = Schema.Struct({
26
+ id: Schema.Number,
27
+ name: Schema.String,
28
+ });
29
+
30
+ /** Odoo's on-the-wire many2one shape: the classic `[id, name]` pair. */
31
+ const Many2OneWirePair = Schema.Tuple([Schema.Number, Schema.String]);
32
+
33
+ /**
34
+ * Decode a present many2one from its wire `[id, name]` pair to a
35
+ * {@link Many2OneRefValue}. Use {@link Many2OneRefOrNull} for a field that can
36
+ * be empty (Odoo sends `false`, not `null`, for an unset many2one).
37
+ *
38
+ * WIRE FACT: the `[id, name]` pair requires the classic `_classic_read` load
39
+ * (the `search_read`/`read` default). A context that changes the load (e.g. a
40
+ * future `load=None`) shifts the shape; strict decode then surfaces it as
41
+ * {@link SchemaDriftError} rather than casting past it.
42
+ */
43
+ export const Many2OneRefFromWire: Schema.Codec<Many2OneRefValue, readonly [number, string]> =
44
+ Many2OneWirePair.pipe(
45
+ Schema.decodeTo(
46
+ Many2OneRefValue,
47
+ SchemaTransformation.transform({
48
+ decode: ([id, name]) => ({ id, name }),
49
+ encode: ({ id, name }) => [id, name] as const,
50
+ }),
51
+ ),
52
+ );
53
+
54
+ /** Ergonomic alias for {@link Many2OneRefFromWire} (the present-ref schema). */
55
+ export const Many2OneRef = Many2OneRefFromWire;
56
+
57
+ /** The wire shape of a nullable many2one: the `[id, name]` pair or `false`. */
58
+ const Many2OneWireOrFalse = Schema.Union([Many2OneWirePair, Schema.Literal(false)]);
59
+
60
+ /**
61
+ * Decode a nullable many2one: the wire `[id, name]` pair → {@link Many2OneRefValue},
62
+ * or Odoo's `false` empty → `null`. This is the schema a row declares for a
63
+ * many2one that can be unset, e.g. `company_id: Many2OneRefOrNull`.
64
+ */
65
+ export const Many2OneRefOrNull: Schema.Codec<
66
+ Many2OneRefValue | null,
67
+ readonly [number, string] | false
68
+ > = Many2OneWireOrFalse.pipe(
69
+ Schema.decodeTo(
70
+ Schema.NullOr(Many2OneRefValue),
71
+ SchemaTransformation.transform({
72
+ decode: (wire) => (wire === false ? null : { id: wire[0], name: wire[1] }),
73
+ encode: (value) => (value === null ? (false as const) : ([value.id, value.name] as const)),
74
+ }),
75
+ ),
76
+ );
77
+
78
+ // --- dates & datetimes ------------------------------------------------------
79
+
80
+ // Odoo Date is `"YYYY-MM-DD"`; Datetime is `"YYYY-MM-DD HH:MM:SS"`, both naive.
81
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
82
+ const DATETIME_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
83
+
84
+ const pad = (n: number, width: number): string => n.toString().padStart(width, "0");
85
+
86
+ const formatUtcDate = (date: Date): string =>
87
+ `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad(date.getUTCDate(), 2)}`;
88
+
89
+ const formatUtcDateTime = (date: Date): string =>
90
+ `${formatUtcDate(date)} ${pad(date.getUTCHours(), 2)}:${pad(date.getUTCMinutes(), 2)}:${pad(
91
+ date.getUTCSeconds(),
92
+ 2,
93
+ )}`;
94
+
95
+ /** A decode/encode failure as a v4 schema issue on the offending value. */
96
+ const invalid = (value: unknown, message: string): SchemaIssue.InvalidValue =>
97
+ new SchemaIssue.InvalidValue(Option.some(value), { message });
98
+
99
+ /**
100
+ * Odoo `Date` field: `"YYYY-MM-DD"` → a `Date` at UTC midnight of that day.
101
+ *
102
+ * A `Date` is an instant, and Odoo's `Date` carries no time or zone. We anchor
103
+ * it at UTC midnight and NEVER localize — reading back `getUTCFullYear()`/
104
+ * `getUTCMonth()`/`getUTCDate()` reproduces the exact wire day on any host,
105
+ * whereas the local accessors could roll a day depending on the runner's tz.
106
+ * Decision (records design, 2026-07-10): decode as UTC, documented, never
107
+ * silently localized.
108
+ */
109
+ export const OdooDate: Schema.Codec<Date, string> = Schema.String.pipe(
110
+ Schema.decodeTo(
111
+ Schema.Date,
112
+ SchemaTransformation.transformOrFail({
113
+ decode: (input) => {
114
+ if (!DATE_RE.test(input)) {
115
+ return Effect.fail(invalid(input, "expected YYYY-MM-DD"));
116
+ }
117
+ const ms = Date.parse(`${input}T00:00:00Z`);
118
+ return Number.isNaN(ms)
119
+ ? Effect.fail(invalid(input, "not a valid calendar date"))
120
+ : Effect.succeed(new Date(ms));
121
+ },
122
+ encode: (date) =>
123
+ Number.isNaN(date.getTime())
124
+ ? Effect.fail(invalid(date, "invalid Date"))
125
+ : Effect.succeed(formatUtcDate(date)),
126
+ }),
127
+ ),
128
+ );
129
+
130
+ /**
131
+ * Odoo `Datetime` field: `"YYYY-MM-DD HH:MM:SS"` → a `Date` parsed as UTC.
132
+ *
133
+ * Odoo stores and transmits datetimes as naive strings that are UTC by
134
+ * convention. A JS `Date` is a single instant, so parsing the wire string AS
135
+ * UTC is the faithful, non-localizing choice: it names the same instant Odoo
136
+ * meant, with no host-tz shift. We never apply the session tz here — that is a
137
+ * presentation concern the caller owns. Decision (records design, 2026-07-10).
138
+ */
139
+ export const OdooDateTime: Schema.Codec<Date, string> = Schema.String.pipe(
140
+ Schema.decodeTo(
141
+ Schema.Date,
142
+ SchemaTransformation.transformOrFail({
143
+ decode: (input) => {
144
+ if (!DATETIME_RE.test(input)) {
145
+ return Effect.fail(invalid(input, "expected YYYY-MM-DD HH:MM:SS"));
146
+ }
147
+ const ms = Date.parse(`${input.replace(" ", "T")}Z`);
148
+ return Number.isNaN(ms)
149
+ ? Effect.fail(invalid(input, "not a valid calendar datetime"))
150
+ : Effect.succeed(new Date(ms));
151
+ },
152
+ encode: (date) =>
153
+ Number.isNaN(date.getTime())
154
+ ? Effect.fail(invalid(date, "invalid Date"))
155
+ : Effect.succeed(formatUtcDateTime(date)),
156
+ }),
157
+ ),
158
+ );
159
+
160
+ /**
161
+ * Lift a `Codec<Date, string>` into one that also accepts Odoo's `false`
162
+ * empty, decoding it to `null`. Shared by the date and datetime nullable
163
+ * variants so the false↔null seam lives in exactly one place. Delegation to
164
+ * the base codec runs through `SchemaParser.*Effect`, which fails with a
165
+ * `SchemaIssue.Issue` directly — the v4 analogue of v3's
166
+ * `ParseResult.decodeUnknown` delegation.
167
+ */
168
+ const orFalseNull = (
169
+ base: Schema.Codec<Date, string>,
170
+ ): Schema.Codec<Date | null, string | false> =>
171
+ Schema.Union([Schema.String, Schema.Literal(false)]).pipe(
172
+ Schema.decodeTo(
173
+ Schema.NullOr(Schema.Date),
174
+ SchemaTransformation.transformOrFail({
175
+ decode: (wire, options) =>
176
+ wire === false
177
+ ? Effect.succeed(null)
178
+ : SchemaParser.decodeUnknownEffect(base)(wire, options),
179
+ encode: (value, options) =>
180
+ value === null
181
+ ? Effect.succeed(false as const)
182
+ : SchemaParser.encodeUnknownEffect(base)(value, options),
183
+ }),
184
+ ),
185
+ );
186
+
187
+ /** {@link OdooDate} with Odoo's `false` empty decoding to `null`. */
188
+ export const OdooDateOrNull: Schema.Codec<Date | null, string | false> = orFalseNull(OdooDate);
189
+
190
+ /** {@link OdooDateTime} with Odoo's `false` empty decoding to `null`. */
191
+ export const OdooDateTimeOrNull: Schema.Codec<Date | null, string | false> =
192
+ orFalseNull(OdooDateTime);
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pure compilation of a declared {@link RecordSpec} into the Odoo `web_read`/
3
+ * `web_search_read` `specification` payload (17+). No I/O: given the field graph,
4
+ * emit `{ field: field_spec }` where a scalar is `{}`, a many2one is
5
+ * `{ fields: <nested> }`, and an x2many is `{ fields: <nested>, limit? }`. The
6
+ * server resolves each relation once over the whole co-recordset, so a compiled
7
+ * spec is exactly ONE batched round trip — no N+1.
8
+ *
9
+ * CYCLES. Eager child specs cannot form an object cycle in a strict language
10
+ * (you cannot reference a `RecordSpec` inside its own `defineRecord` call), so a
11
+ * cyclic declaration is inexpressible by construction and the normal path is
12
+ * total. We still guard defensively by RecordSpec IDENTITY along the ancestor
13
+ * chain — a spec forced into a cycle (via mutation / `any`) would otherwise
14
+ * recurse forever; instead it throws {@link RecordSpecCycleError}, which the
15
+ * client surfaces as a defect (a declaration bug, never a runtime condition). A
16
+ * finite DAG that references the same MODEL through two distinct specs is fine —
17
+ * only a repeated spec object is a cycle.
18
+ */
19
+ import type { RecordSpec } from "./recordModel.ts";
20
+ import type { HasId } from "./typed.ts";
21
+
22
+ /** A declared record graph that references itself — a declaration bug, not a
23
+ * runtime fault. Thrown by {@link compileSpecification}; the client turns it into
24
+ * an `Effect` defect. */
25
+ export class RecordSpecCycleError extends Error {
26
+ readonly path: ReadonlyArray<string>;
27
+ constructor(path: ReadonlyArray<string>) {
28
+ super(
29
+ `RecordSpec declaration cycle: ${path.join(" -> ")} -> ${path[path.length - 1]}. ` +
30
+ `A relation subtree references a record spec already on its ancestor chain; ` +
31
+ `the specification would be infinite. Declared record graphs must be finite.`,
32
+ );
33
+ this.name = "RecordSpecCycleError";
34
+ this.path = path;
35
+ }
36
+ }
37
+
38
+ /** A declared record with no compilable fields — an empty `specification` would
39
+ * ask the server for nothing. Thrown by {@link compileSpecification}. */
40
+ export class EmptyRecordSpecError extends Error {
41
+ readonly model: string;
42
+ constructor(model: string) {
43
+ super(
44
+ `RecordSpec for "${model}" compiles to an empty specification (no fields). ` +
45
+ `A declared record must request at least its id.`,
46
+ );
47
+ this.name = "EmptyRecordSpecError";
48
+ this.model = model;
49
+ }
50
+ }
51
+
52
+ /** `true` iff the spec declares at least one relation field (gate key). */
53
+ export const hasRelations = <A extends HasId, I>(spec: RecordSpec<A, I>): boolean =>
54
+ Object.values(spec.fields).some((m) => m.kind !== "scalar");
55
+
56
+ const compile = (
57
+ // oxlint-disable-next-line no-explicit-any -- Schema is invariant; the compiler
58
+ // walks the erased metadata graph structurally (see FieldMeta).
59
+ spec: RecordSpec<any, any>,
60
+ ancestors: ReadonlyArray<RecordSpec<any, any>>,
61
+ ): Record<string, unknown> => {
62
+ if (ancestors.includes(spec)) {
63
+ throw new RecordSpecCycleError([...ancestors.map((a) => a.model), spec.model]);
64
+ }
65
+ const nextAncestors = [...ancestors, spec];
66
+ const out: Record<string, unknown> = {};
67
+
68
+ for (const [name, meta] of Object.entries(spec.fields)) {
69
+ if (meta.kind === "scalar") {
70
+ out[name] = {};
71
+ } else if (meta.kind === "many2one") {
72
+ out[name] = { fields: compile(meta.child, nextAncestors) };
73
+ } else {
74
+ out[name] = {
75
+ fields: compile(meta.child, nextAncestors),
76
+ ...(meta.limit !== undefined ? { limit: meta.limit } : {}),
77
+ };
78
+ }
79
+ }
80
+
81
+ if (Object.keys(out).length === 0) {
82
+ throw new EmptyRecordSpecError(spec.model);
83
+ }
84
+ return out;
85
+ };
86
+
87
+ /**
88
+ * Compile a declared {@link RecordSpec} into its nested `specification` payload.
89
+ * Pure and total for the by-construction-acyclic normal case; throws
90
+ * {@link RecordSpecCycleError} on a forced cycle and {@link EmptyRecordSpecError}
91
+ * on an empty declaration. The client runs it under `Effect.sync`, so either
92
+ * throw becomes a precise defect.
93
+ */
94
+ export const compileSpecification = <A extends HasId, I>(
95
+ spec: RecordSpec<A, I>,
96
+ ): Record<string, unknown> => compile(spec, []);