@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,180 @@
1
+ /**
2
+ * `TypedRecordSet` — an immutable, decoded snapshot of rows bound to its
3
+ * `(rpc, model)` origin, with explicit, batched relation traversal — the
4
+ * CLASSIC tier of the records design (the classic `[id, name]`-pair protocol,
5
+ * works on 16+; the spec tier in `recordModel.ts` needs 17+). Traversal is always ONE `read` per relation per
6
+ * recordset — never per row — so the N+1 storm is structurally impossible: there
7
+ * is no per-record fetch API to misuse. The snapshot is never mutated; a fetch
8
+ * produces a fresh {@link RelatedMap}, and the caller joins explicitly.
9
+ */
10
+ import { Effect, Schema } from "effect";
11
+ import { SchemaDriftError } from "../errors/schema.ts";
12
+ import type { Rpc } from "../rpc.ts";
13
+ import type { TransportCallError } from "../transport.ts";
14
+ import { collectRefIds, makeRelatedMap, type RelatedMap } from "./related.ts";
15
+ import type { Many2OneRefValue } from "./relations.ts";
16
+
17
+ /** A decoded row must carry its `id` — every traversal and join keys on it. */
18
+ export type HasId = { readonly id: number };
19
+
20
+ /**
21
+ * The field names of `Row` whose decoded value is a many2one reference
22
+ * (`Many2OneRefValue | null`) — the only fields a relation can be traversed
23
+ * through. A compile-time constraint: passing any other key to
24
+ * {@link TypedRecordSet.fetchRelated}/`joinRelated` is a type error, not a
25
+ * runtime surprise.
26
+ */
27
+ export type Many2OneRefField<Row> = {
28
+ readonly [K in keyof Row]: Row[K] extends Many2OneRefValue | null ? K : never;
29
+ }[keyof Row];
30
+
31
+ /**
32
+ * An immutable snapshot of typed rows with explicit relation traversal. Obtain
33
+ * one from `OdooClient.searchRecordsTyped`; it is bound to the `Rpc` seam and
34
+ * model it was read from so `fetchRelated` can batch a co-model `read`.
35
+ */
36
+ export interface TypedRecordSet<Row extends HasId> extends Iterable<Row> {
37
+ /** The model these rows were read from. */
38
+ readonly model: string;
39
+ /** The decoded rows, in server order. Immutable. */
40
+ readonly rows: ReadonlyArray<Row>;
41
+ /** Each row's `id`, in row order. */
42
+ readonly ids: ReadonlyArray<number>;
43
+ /** The number of rows. */
44
+ readonly length: number;
45
+
46
+ /**
47
+ * Fetch the related records behind a many2one `field` in ONE batched `read`
48
+ * over the distinct, non-null ids across the whole snapshot, decoded through
49
+ * `schema`. Returns a {@link RelatedMap} keyed by related id — a fresh
50
+ * snapshot; the recordset is never back-filled.
51
+ *
52
+ * ZERO round trips when no row references a related record (all empty). The
53
+ * batched-single-read guarantee is the entire point of this API and is
54
+ * asserted in the tests, not merely documented.
55
+ */
56
+ readonly fetchRelated: <K extends Many2OneRefField<Row>, A extends HasId, I = A>(
57
+ field: K,
58
+ relatedModel: string,
59
+ schema: Schema.Codec<A, I>,
60
+ fields?: ReadonlyArray<string>,
61
+ ) => Effect.Effect<RelatedMap<A>, TransportCallError>;
62
+
63
+ /**
64
+ * Like {@link fetchRelated}, but returns each row paired with its joined
65
+ * related record (`null` when the many2one is empty or unresolved). One
66
+ * explicit, batched call — named to keep "this is a fetch" legible at the call
67
+ * site.
68
+ */
69
+ readonly joinRelated: <K extends Many2OneRefField<Row>, A extends HasId, I = A>(
70
+ field: K,
71
+ relatedModel: string,
72
+ schema: Schema.Codec<A, I>,
73
+ fields?: ReadonlyArray<string>,
74
+ ) => Effect.Effect<ReadonlyArray<readonly [Row, A | null]>, TransportCallError>;
75
+ }
76
+
77
+ /** The subset of the {@link Rpc} service {@link TypedRecordSet} depends on. */
78
+ type RpcSeam = Pick<typeof Rpc.Service, "callKw">;
79
+
80
+ /** Boundary decode: a related-`read` payload that fails the caller schema is drift. */
81
+ const decodeRows =
82
+ <A, I>(schema: Schema.Codec<A, I>, context: string) =>
83
+ (raw: unknown): Effect.Effect<ReadonlyArray<A>, SchemaDriftError> =>
84
+ Schema.decodeUnknownEffect(Schema.Array(schema))(raw).pipe(
85
+ Effect.mapError((cause) => new SchemaDriftError({ context, payload: raw, cause })),
86
+ );
87
+
88
+ /**
89
+ * Read a many2one field off a row as a traversable ref value, or fail the whole
90
+ * traversal as a defect. The type system already forbids non-ref fields; this
91
+ * guards the dynamic-misuse escape hatch (e.g. an `any`-typed field or a schema
92
+ * that decoded the wrong shape) — a `TypeError`-equivalent `Effect.die` with a
93
+ * precise locator, never a silent skip.
94
+ */
95
+ const readRefField = <Row extends HasId>(
96
+ row: Row,
97
+ field: PropertyKey,
98
+ model: string,
99
+ ): Effect.Effect<Many2OneRefValue | null> => {
100
+ const value = (row as Record<PropertyKey, unknown>)[field];
101
+ if (value === null || value === undefined) {
102
+ return Effect.succeed(null);
103
+ }
104
+ if (typeof value === "object" && typeof (value as { id?: unknown }).id === "number") {
105
+ return Effect.succeed(value as Many2OneRefValue);
106
+ }
107
+ return Effect.die(
108
+ `TypedRecordSet(${model}).fetchRelated: field ${String(field)} of record ${row.id} is not a ` +
109
+ `many2one reference ({ id, name } | null); got ${typeof value}. Declare it with ` +
110
+ `Many2OneRefOrNull so traversal is type-safe.`,
111
+ );
112
+ };
113
+
114
+ /**
115
+ * Construct a {@link TypedRecordSet} over already-decoded rows. Internal: the
116
+ * public entry point is `OdooClient.searchRecordsTyped`, which owns the initial
117
+ * `search_read` decode and passes the resulting rows here.
118
+ */
119
+ export const make = <Row extends HasId>(
120
+ rpc: RpcSeam,
121
+ model: string,
122
+ rows: ReadonlyArray<Row>,
123
+ ): TypedRecordSet<Row> => {
124
+ const ids = rows.map((row) => row.id);
125
+
126
+ const fetchRelated = <K extends Many2OneRefField<Row>, A extends HasId, I = A>(
127
+ field: K,
128
+ relatedModel: string,
129
+ schema: Schema.Codec<A, I>,
130
+ fields?: ReadonlyArray<string>,
131
+ ): Effect.Effect<RelatedMap<A>, TransportCallError> =>
132
+ // Validate every row's field up front (dynamic-misuse guard), then collect
133
+ // the distinct ids purely and issue AT MOST one read.
134
+ Effect.forEach(rows, (row) => readRefField(row, field, model)).pipe(
135
+ Effect.flatMap((refs) => {
136
+ const relatedIds = collectRefIds(refs, (ref) => ref);
137
+ if (relatedIds.length === 0) {
138
+ // No referenced records → no round trip. Asserted in the tests.
139
+ return Effect.succeed(makeRelatedMap<A>([]));
140
+ }
141
+ return rpc
142
+ .callKw(
143
+ relatedModel,
144
+ "read",
145
+ [],
146
+ fields === undefined ? {} : { fields },
147
+ { ids: relatedIds },
148
+ )
149
+ .pipe(
150
+ Effect.flatMap(decodeRows(schema, `${relatedModel}.read`)),
151
+ Effect.map((relatedRows) => makeRelatedMap(relatedRows)),
152
+ );
153
+ }),
154
+ );
155
+
156
+ const joinRelated = <K extends Many2OneRefField<Row>, A extends HasId, I = A>(
157
+ field: K,
158
+ relatedModel: string,
159
+ schema: Schema.Codec<A, I>,
160
+ fields?: ReadonlyArray<string>,
161
+ ): Effect.Effect<ReadonlyArray<readonly [Row, A | null]>, TransportCallError> =>
162
+ fetchRelated(field, relatedModel, schema, fields).pipe(
163
+ Effect.map((related) =>
164
+ rows.map(
165
+ (row) =>
166
+ [row, related.get((row as Record<PropertyKey, unknown>)[field] as never)] as const,
167
+ ),
168
+ ),
169
+ );
170
+
171
+ return {
172
+ model,
173
+ rows,
174
+ ids,
175
+ length: rows.length,
176
+ [Symbol.iterator]: () => rows[Symbol.iterator](),
177
+ fetchRelated,
178
+ joinRelated,
179
+ };
180
+ };
package/src/rpc.ts ADDED
@@ -0,0 +1,219 @@
1
+ import { Context, Effect, Layer, Option, Schema } from "effect";
2
+ import { SchemaDriftError } from "./errors/schema.ts";
3
+ import * as SingleFlight from "./internal/singleFlight.ts";
4
+ import {
5
+ type CallKwParams,
6
+ Transport,
7
+ type TransportCallError,
8
+ type TransportDialect,
9
+ } from "./transport.ts";
10
+
11
+ /** An Odoo `context` dict: opaque keys, values we do not model. */
12
+ export type OdooContext = Record<string, unknown>;
13
+
14
+ /** A plain object (a mergeable context), excluding arrays and `null`. */
15
+ const isPlainObject = (value: unknown): value is OdooContext =>
16
+ typeof value === "object" && value !== null && !Array.isArray(value);
17
+
18
+ /** Copy `kwargs` without its `context` key (never mutates the caller's object). */
19
+ const omitContext = (kwargs: OdooContext): OdooContext => {
20
+ const { context: _context, ...rest } = kwargs;
21
+ return rest;
22
+ };
23
+
24
+ /**
25
+ * The session/base Odoo `context` — the innermost, lowest-precedence layer of
26
+ * the context merge (e.g. `uid`, `lang`, `tz` established at authentication).
27
+ *
28
+ * Provided by the auth/transport layer that owns the session; absent here it
29
+ * defaults to `{}`. It is read once, when the {@link Rpc} layer is built.
30
+ */
31
+ export class GlobalContext extends Context.Service<
32
+ GlobalContext,
33
+ OdooContext
34
+ >()("odoo-rpc-ts/GlobalContext") {}
35
+
36
+ /**
37
+ * The `call_kw` choke point. Every high-level operation funnels through here so
38
+ * the Odoo `context` is merged in exactly one place, with a single, documented
39
+ * precedence (see {@link callKw}).
40
+ */
41
+ export class Rpc extends Context.Service<
42
+ Rpc,
43
+ {
44
+ /** The underlying transport's wire dialect, for ops that must branch on it. */
45
+ readonly dialect: TransportDialect;
46
+ /**
47
+ * Execute one `call_kw`, merging the effective `context` and forwarding to
48
+ * the {@link Transport}. Caller inputs are never mutated — `args`, `kwargs`,
49
+ * and every context object are treated as read-only and copied.
50
+ *
51
+ * Context precedence (lowest → highest, later wins):
52
+ * session/base ({@link GlobalContext}) < layer overrides
53
+ * < `kwargs.context` < `options.context`.
54
+ *
55
+ * A `context` key inside `kwargs` is treated as a caller-level context (the
56
+ * ergonomic form `callKw(m, meth, [], { context })`), merged just below
57
+ * `options.context` and then stripped from `kwargs` so it never wires twice.
58
+ * If it is present but not a plain object it is a caller defect and fails
59
+ * with `Effect.die`.
60
+ */
61
+ readonly callKw: (
62
+ model: string,
63
+ method: string,
64
+ args: ReadonlyArray<unknown>,
65
+ kwargs?: OdooContext,
66
+ options?: { readonly context?: OdooContext; readonly ids?: ReadonlyArray<number> },
67
+ ) => Effect.Effect<unknown, TransportCallError>;
68
+ }
69
+ >()("odoo-rpc-ts/Rpc") {}
70
+
71
+ /**
72
+ * The provider seam for a lazily-seeded session/base context.
73
+ *
74
+ * A `Layer` resolves eagerly at build time, but the base tier must be lazy and
75
+ * retryable-on-failure (a transport error resolving it must surface as the
76
+ * failing call's error, never be baked in). So instead of a value provider we
77
+ * take an `Effect` that yields the base context; {@link buildRpc} runs it behind
78
+ * a success-only single-flight on the FIRST `callKw` (and retries on failure).
79
+ *
80
+ * Merge position: below the layer overrides, above the static {@link GlobalContext}
81
+ * tag (which, when both are present, seeds the innermost defaults).
82
+ */
83
+ export type GlobalContextProvider = Effect.Effect<OdooContext, TransportCallError>;
84
+
85
+ const ContextRecord = Schema.Record(Schema.String, Schema.Unknown);
86
+
87
+ /**
88
+ * Decode a raw `res.users.context_get` result into an {@link OdooContext};
89
+ * drift (a non-object payload) fails as {@link SchemaDriftError}, which is part
90
+ * of {@link TransportCallError} and so propagates as the seeding call's error.
91
+ */
92
+ const decodeContext = (raw: unknown): Effect.Effect<OdooContext, SchemaDriftError> =>
93
+ Schema.decodeUnknownEffect(ContextRecord)(raw).pipe(
94
+ Effect.mapError(
95
+ (cause) => new SchemaDriftError({ context: "res.users.context_get", payload: raw, cause }),
96
+ ),
97
+ );
98
+
99
+ /**
100
+ * The shared {@link Rpc} service constructor. `provider`, when given, seeds the
101
+ * base tier lazily (single-flight on first call). The static {@link GlobalContext}
102
+ * tag is always read (defaulting to `{}`) and sits at the very bottom.
103
+ */
104
+ const buildRpc = (
105
+ transport: typeof Transport.Service,
106
+ config: { readonly globalContext?: OdooContext } | undefined,
107
+ provider: GlobalContextProvider | undefined,
108
+ ): Effect.Effect<typeof Rpc.Service> =>
109
+ Effect.gen(function* () {
110
+ const staticBase = Option.getOrElse(
111
+ yield* Effect.serviceOption(GlobalContext),
112
+ (): OdooContext => ({}),
113
+ );
114
+ const layerOverrides = config?.globalContext ?? {};
115
+
116
+ // No provider: the base tier is the static tag value (today's behavior,
117
+ // resolved once at build). With a provider: resolve it lazily behind a
118
+ // success-only single-flight and layer it over the static base.
119
+ const resolveBase: Effect.Effect<OdooContext, TransportCallError> =
120
+ provider === undefined
121
+ ? Effect.succeed(staticBase)
122
+ : yield* SingleFlight.make(provider).pipe(
123
+ Effect.map((sf) =>
124
+ sf.get.pipe(Effect.map((seeded): OdooContext => ({ ...staticBase, ...seeded }))),
125
+ ),
126
+ );
127
+
128
+ const callKw = (
129
+ model: string,
130
+ method: string,
131
+ args: ReadonlyArray<unknown>,
132
+ kwargs?: OdooContext,
133
+ options?: { readonly context?: OdooContext; readonly ids?: ReadonlyArray<number> },
134
+ ): Effect.Effect<unknown, TransportCallError> => {
135
+ // Pull any caller-supplied `kwargs.context` out of the spread so it is
136
+ // merged at the caller tier (not silently clobbered) and never wired twice.
137
+ const hasKwargsContext = kwargs !== undefined && "context" in kwargs;
138
+ const kwargsContext = hasKwargsContext ? kwargs["context"] : undefined;
139
+ if (hasKwargsContext && !isPlainObject(kwargsContext)) {
140
+ return Effect.die(
141
+ `Rpc.callKw: kwargs.context for "${model}.${method}" must be a plain object, ` +
142
+ `got ${typeof kwargsContext}.`,
143
+ );
144
+ }
145
+ const restKwargs = hasKwargsContext ? omitContext(kwargs) : (kwargs ?? {});
146
+
147
+ return resolveBase.pipe(
148
+ Effect.flatMap((base) => {
149
+ const context: OdooContext = {
150
+ ...base,
151
+ ...layerOverrides,
152
+ ...(kwargsContext as OdooContext | undefined),
153
+ ...options?.context,
154
+ };
155
+ const params: CallKwParams = {
156
+ model,
157
+ method,
158
+ args,
159
+ kwargs: { ...restKwargs, context },
160
+ ...(options?.ids !== undefined ? { ids: options.ids } : {}),
161
+ };
162
+ return transport.callKw(params);
163
+ }),
164
+ );
165
+ };
166
+
167
+ return { dialect: transport.dialect, callKw };
168
+ });
169
+
170
+ /**
171
+ * Build the {@link Rpc} layer over a {@link Transport}, optionally baking in
172
+ * layer-level `globalContext` overrides that sit above the session/base context
173
+ * but below any per-call context, and/or a {@link GlobalContextProvider} that
174
+ * lazily seeds the base tier on the first call.
175
+ */
176
+ export const layerWith = (config?: {
177
+ readonly globalContext?: OdooContext;
178
+ readonly globalContextProvider?: GlobalContextProvider;
179
+ }): Layer.Layer<Rpc, never, Transport> =>
180
+ Layer.effect(
181
+ Rpc,
182
+ Effect.gen(function* () {
183
+ const transport = yield* Transport;
184
+ return yield* buildRpc(transport, config, config?.globalContextProvider);
185
+ }),
186
+ );
187
+
188
+ /**
189
+ * The {@link Rpc} layer with the base tier seeded from the ambient
190
+ * {@link Transport}'s server-side user context.
191
+ *
192
+ * Resolves `res.users.context_get` (an `@api.model` method — kwargs-only, so it
193
+ * works identically over `execute_kw` and the JSON-2 by-name dialect) exactly
194
+ * once, lazily on the first `callKw`, behind a success-only single-flight. A
195
+ * transport/decoding failure while seeding is NOT swallowed: it surfaces as that
196
+ * first call's error and the seed is retried on the next call.
197
+ */
198
+ export const layerSeeded = (config?: {
199
+ readonly globalContext?: OdooContext;
200
+ }): Layer.Layer<Rpc, never, Transport> =>
201
+ Layer.effect(
202
+ Rpc,
203
+ Effect.gen(function* () {
204
+ const transport = yield* Transport;
205
+ const provider: GlobalContextProvider = transport
206
+ .callKw({ model: "res.users", method: "context_get", args: [], kwargs: {} })
207
+ .pipe(Effect.flatMap(decodeContext));
208
+ return yield* buildRpc(transport, config, provider);
209
+ }),
210
+ );
211
+
212
+ /**
213
+ * The default {@link Rpc} layer: base tier seeded from the server-side user
214
+ * context ({@link layerSeeded}), so translated fields (stage names, selection
215
+ * labels, …) come back in the user's language instead of the source terms —
216
+ * matching odoo-rpc-py's `seed_context=True` default. Opt out with
217
+ * {@link layerWith} (no provider) when calls must carry no ambient context.
218
+ */
219
+ export const layer: Layer.Layer<Rpc, never, Transport> = layerSeeded();
@@ -0,0 +1,282 @@
1
+ /**
2
+ * `DbService` — the master-password-gated database-management endpoints exposed
3
+ * by Odoo's `db` service over `POST /jsonrpc` (`exp_list`, `exp_db_exist`,
4
+ * `exp_create_database`, `exp_drop`, `exp_duplicate_database`, `exp_dump`,
5
+ * `exp_restore`, `exp_change_admin_password`).
6
+ *
7
+ * Unlike the model-level `Transport`, this service is **database-less and
8
+ * credential-less**: it authenticates nothing and speaks to no database. The
9
+ * only secret is the per-call master password (Odoo's `admin_passwd`), taken as
10
+ * a `Redacted<string>` and unwrapped solely at the point the request body is
11
+ * built. There is therefore no `Transport` tag, no uid, and no session — every
12
+ * function takes the server `url: URL` explicitly and requires only an abstract
13
+ * `HttpClient`.
14
+ *
15
+ * A wrong master password surfaces as `odoo.exceptions.AccessDenied`, which the
16
+ * shared JSON-RPC choke point ({@link mapJsonRpcError}) maps to
17
+ * {@link OdooAuthenticationError} — same as any other auth rejection.
18
+ *
19
+ * NODE ORIENTATION: {@link dump} and {@link restore} carry the whole backup in
20
+ * memory as a `Uint8Array`. This is inherent to the wire contract — Odoo's
21
+ * `exp_dump` returns the archive base64-encoded inside the JSON-RPC `result`,
22
+ * and `exp_restore` takes it back the same way — so there is no streaming path
23
+ * to preserve. Fine for the CI/ops-sized databases this is meant for; not a
24
+ * mechanism for multi-gigabyte production dumps.
25
+ *
26
+ * Wire arities verified against `odoo/service/db.py` on 16.0 and 19.0 (stable
27
+ * 16–19). `dispatch` strips the leading master password for every `exp_*`
28
+ * method except the four unauthenticated ones (`list`, `db_exist`, `list_lang`,
29
+ * `server_version`); `exp_restore` has NO `neutralize` parameter on any version.
30
+ */
31
+ import { Effect, Encoding, Redacted, Schema } from "effect";
32
+ import type { FaultCallSite } from "../errors/mapFault.ts";
33
+ import { SchemaDriftError } from "../errors/schema.ts";
34
+ import type { OdooAuthenticationError } from "../errors/auth.ts";
35
+ import type { OdooServerFault } from "../errors/server.ts";
36
+ import type { SessionExpiredError } from "../errors/session.ts";
37
+ import { OdooTransportError } from "../errors/transport.ts";
38
+ import { HttpClient, HttpClientRequest } from "../internal/platform.ts";
39
+ import {
40
+ buildRequest,
41
+ JsonRpcResponse,
42
+ mapJsonRpcError,
43
+ nextRequestId,
44
+ } from "../protocol/jsonrpc.ts";
45
+
46
+ /** The failure channel a single `db` round trip can produce. */
47
+ type DbCallError =
48
+ | OdooTransportError
49
+ | SchemaDriftError
50
+ | SessionExpiredError
51
+ | OdooServerFault
52
+ | OdooAuthenticationError;
53
+
54
+ /** Join the base URL with the `/jsonrpc` path, tolerating a trailing slash. */
55
+ const jsonRpcEndpoint = (url: URL): string => `${url.href.replace(/\/+$/, "")}/jsonrpc`;
56
+
57
+ /**
58
+ * One `db`-service round trip: build the `{service:"db", method, args}`
59
+ * envelope, POST it, decode the JSON-RPC response, and either return the raw
60
+ * `result` or map the fault through the shared choke point.
61
+ *
62
+ * GOTCHA: never record `args` on the span — the master password rides in
63
+ * position 0 for gated methods. Only the method name is safe.
64
+ */
65
+ const dbCall = (
66
+ url: URL,
67
+ method: string,
68
+ args: ReadonlyArray<unknown>,
69
+ ): Effect.Effect<unknown, DbCallError, HttpClient.HttpClient> =>
70
+ Effect.gen(function* () {
71
+ const client = yield* HttpClient.HttpClient;
72
+ const endpoint = jsonRpcEndpoint(url);
73
+ const id = nextRequestId();
74
+ const site: FaultCallSite = { method: `db.${method}` };
75
+
76
+ const request = HttpClientRequest.post(endpoint).pipe(
77
+ HttpClientRequest.setHeader("Content-Type", "application/json"),
78
+ HttpClientRequest.bodyJsonUnsafe(buildRequest({ service: "db", method, args }, id)),
79
+ );
80
+
81
+ const payload: unknown = yield* client.execute(request).pipe(
82
+ Effect.flatMap((response) => response.json),
83
+ Effect.mapError((cause) =>
84
+ OdooTransportError.fromHttpClientError({ method: "POST", url: endpoint }, cause),
85
+ ),
86
+ );
87
+
88
+ const decoded = yield* Schema.decodeUnknownEffect(JsonRpcResponse)(payload).pipe(
89
+ Effect.mapError(
90
+ (cause) => new SchemaDriftError({ context: "odoo.db response", payload, cause }),
91
+ ),
92
+ );
93
+
94
+ if ("error" in decoded) {
95
+ return yield* Effect.fail(mapJsonRpcError(decoded.error, site));
96
+ }
97
+ return decoded.result;
98
+ }).pipe(Effect.withSpan("odoo.db", { attributes: { "odoo.db.method": method } }));
99
+
100
+ /** Decode a `db` result through `schema`, raising {@link SchemaDriftError} on drift. */
101
+ const decodeResult = <A, I>(
102
+ schema: Schema.Codec<A, I>,
103
+ context: string,
104
+ result: unknown,
105
+ ): Effect.Effect<A, SchemaDriftError> =>
106
+ Schema.decodeUnknownEffect(schema)(result).pipe(
107
+ Effect.mapError((cause) => new SchemaDriftError({ context, payload: result, cause })),
108
+ );
109
+
110
+ /**
111
+ * List the databases the server is willing to expose (honours `list_db` and any
112
+ * `dbfilter`). Unauthenticated — no master password required.
113
+ */
114
+ export const listDatabases = (
115
+ url: URL,
116
+ ): Effect.Effect<ReadonlyArray<string>, DbCallError, HttpClient.HttpClient> =>
117
+ dbCall(url, "list", []).pipe(
118
+ Effect.flatMap((result) =>
119
+ decodeResult(Schema.Array(Schema.String), "odoo.db exp_list", result),
120
+ ),
121
+ );
122
+
123
+ /** Whether a database of the given name exists. Unauthenticated. */
124
+ export const exists = (
125
+ url: URL,
126
+ name: string,
127
+ ): Effect.Effect<boolean, DbCallError, HttpClient.HttpClient> =>
128
+ dbCall(url, "db_exist", [name]).pipe(
129
+ Effect.flatMap((result) => decodeResult(Schema.Boolean, "odoo.db exp_db_exist", result)),
130
+ );
131
+
132
+ /** Options for {@link create}. `master` and `adminPassword` are the two secrets. */
133
+ export interface CreateOptions {
134
+ readonly master: Redacted.Redacted<string>;
135
+ readonly name: string;
136
+ /** Install demo data. Defaults to `false`. */
137
+ readonly demo?: boolean;
138
+ /** Load language / locale (e.g. `"en_US"`). Defaults to `"en_US"`. */
139
+ readonly lang?: string;
140
+ /** Login of the initial admin user. Defaults to `"admin"`. */
141
+ readonly adminLogin?: string;
142
+ /** Password of the initial admin user. */
143
+ readonly adminPassword: Redacted.Redacted<string>;
144
+ /** ISO country code to seed localization (e.g. `"US"`). */
145
+ readonly countryCode?: string;
146
+ /** Company phone to seed. */
147
+ readonly phone?: string;
148
+ }
149
+
150
+ /**
151
+ * Create a fresh database and its initial admin user.
152
+ *
153
+ * Wire (after `dispatch` strips the master): `exp_create_database(db_name,
154
+ * demo, lang, user_password='admin', login='admin', country_code=None,
155
+ * phone=None)`.
156
+ */
157
+ export const create = (
158
+ url: URL,
159
+ options: CreateOptions,
160
+ ): Effect.Effect<void, DbCallError, HttpClient.HttpClient> =>
161
+ dbCall(url, "create_database", [
162
+ Redacted.value(options.master),
163
+ options.name,
164
+ options.demo ?? false,
165
+ options.lang ?? "en_US",
166
+ Redacted.value(options.adminPassword),
167
+ options.adminLogin ?? "admin",
168
+ options.countryCode ?? null,
169
+ options.phone ?? null,
170
+ ]).pipe(Effect.asVoid);
171
+
172
+ /**
173
+ * Drop a database. Resolves to `true` when a database was actually dropped,
174
+ * `false` when none of that name existed.
175
+ */
176
+ export const drop = (
177
+ url: URL,
178
+ master: Redacted.Redacted<string>,
179
+ name: string,
180
+ ): Effect.Effect<boolean, DbCallError, HttpClient.HttpClient> =>
181
+ dbCall(url, "drop", [Redacted.value(master), name]).pipe(
182
+ Effect.flatMap((result) => decodeResult(Schema.Boolean, "odoo.db exp_drop", result)),
183
+ );
184
+
185
+ /** Options for {@link duplicate}. */
186
+ export interface DuplicateOptions {
187
+ /**
188
+ * Neutralize the copy (disable outgoing mail/crons/payment providers, …) so it
189
+ * is safe as a staging database. Maps to `neutralize_database`. Defaults to
190
+ * `false`.
191
+ */
192
+ readonly neutralize?: boolean;
193
+ }
194
+
195
+ /**
196
+ * Duplicate `source` into a new database `target`.
197
+ *
198
+ * Wire (after master strip): `exp_duplicate_database(db_original_name, db_name,
199
+ * neutralize_database=False)`.
200
+ */
201
+ export const duplicate = (
202
+ url: URL,
203
+ master: Redacted.Redacted<string>,
204
+ source: string,
205
+ target: string,
206
+ options: DuplicateOptions = {},
207
+ ): Effect.Effect<void, DbCallError, HttpClient.HttpClient> =>
208
+ dbCall(url, "duplicate_database", [
209
+ Redacted.value(master),
210
+ source,
211
+ target,
212
+ options.neutralize ?? false,
213
+ ]).pipe(Effect.asVoid);
214
+
215
+ /**
216
+ * Dump a database into an in-memory archive.
217
+ *
218
+ * `format` is `"zip"` (default; includes the filestore) or `"dump"` (raw
219
+ * `pg_dump` custom format, database only). Odoo returns the archive
220
+ * base64-encoded inside the JSON-RPC `result`; we decode it to raw bytes.
221
+ */
222
+ export const dump = (
223
+ url: URL,
224
+ master: Redacted.Redacted<string>,
225
+ name: string,
226
+ format: "zip" | "dump" = "zip",
227
+ ): Effect.Effect<Uint8Array, DbCallError, HttpClient.HttpClient> =>
228
+ dbCall(url, "dump", [Redacted.value(master), name, format]).pipe(
229
+ // Odoo returns the archive base64-encoded in `result`; decode to raw bytes,
230
+ // any malformed base64 surfacing as SchemaDriftError like every other drift.
231
+ Effect.flatMap((result) =>
232
+ decodeResult(Schema.Uint8ArrayFromBase64, "odoo.db exp_dump", result),
233
+ ),
234
+ );
235
+
236
+ /** Options for {@link restore}. */
237
+ export interface RestoreOptions {
238
+ /**
239
+ * Restore as a *copy* — regenerate the database uuid and secret so it does not
240
+ * collide with the original in a mail/cron sense. Maps to `copy`. Defaults to
241
+ * `false`.
242
+ *
243
+ * NOTE: `exp_restore` has NO `neutralize` parameter on any supported version;
244
+ * there is deliberately no such option here.
245
+ */
246
+ readonly copy?: boolean;
247
+ }
248
+
249
+ /**
250
+ * Restore a previously {@link dump}ed archive under a new database `name`.
251
+ *
252
+ * The bytes are re-encoded to the base64 string `exp_restore` expects.
253
+ * Wire (after master strip): `exp_restore(db_name, data, copy=False)`.
254
+ */
255
+ export const restore = (
256
+ url: URL,
257
+ master: Redacted.Redacted<string>,
258
+ name: string,
259
+ archive: Uint8Array,
260
+ options: RestoreOptions = {},
261
+ ): Effect.Effect<void, DbCallError, HttpClient.HttpClient> =>
262
+ dbCall(url, "restore", [
263
+ Redacted.value(master),
264
+ name,
265
+ Encoding.encodeBase64(archive),
266
+ options.copy ?? false,
267
+ ]).pipe(Effect.asVoid);
268
+
269
+ /**
270
+ * Change the server master password (`admin_passwd`). `master` is the current
271
+ * password; `next` becomes the new one.
272
+ *
273
+ * Wire (after master strip): `exp_change_admin_password(new_password)`.
274
+ */
275
+ export const changeMasterPassword = (
276
+ url: URL,
277
+ master: Redacted.Redacted<string>,
278
+ next: Redacted.Redacted<string>,
279
+ ): Effect.Effect<void, DbCallError, HttpClient.HttpClient> =>
280
+ dbCall(url, "change_admin_password", [Redacted.value(master), Redacted.value(next)]).pipe(
281
+ Effect.asVoid,
282
+ );