@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.
- package/LICENSE +21 -0
- package/README.md +401 -0
- package/dist/client.d.ts +184 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +277 -0
- package/dist/client.js.map +1 -0
- package/dist/combinators/retryOnSessionExpired.d.ts +13 -0
- package/dist/combinators/retryOnSessionExpired.d.ts.map +1 -0
- package/dist/combinators/retryOnSessionExpired.js +15 -0
- package/dist/combinators/retryOnSessionExpired.js.map +1 -0
- package/dist/commands.d.ts +91 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +69 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +60 -0
- package/dist/config.js.map +1 -0
- package/dist/domain.d.ts +30 -0
- package/dist/domain.d.ts.map +1 -0
- package/dist/domain.js +70 -0
- package/dist/domain.js.map +1 -0
- package/dist/errors/auth.d.ts +27 -0
- package/dist/errors/auth.d.ts.map +1 -0
- package/dist/errors/auth.js +8 -0
- package/dist/errors/auth.js.map +1 -0
- package/dist/errors/index.d.ts +30 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +13 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/errors/mapFault.d.ts +31 -0
- package/dist/errors/mapFault.d.ts.map +1 -0
- package/dist/errors/mapFault.js +48 -0
- package/dist/errors/mapFault.js.map +1 -0
- package/dist/errors/protocol.d.ts +22 -0
- package/dist/errors/protocol.d.ts.map +1 -0
- package/dist/errors/protocol.js +9 -0
- package/dist/errors/protocol.js.map +1 -0
- package/dist/errors/schema.d.ts +22 -0
- package/dist/errors/schema.d.ts.map +1 -0
- package/dist/errors/schema.js +14 -0
- package/dist/errors/schema.js.map +1 -0
- package/dist/errors/server.d.ts +109 -0
- package/dist/errors/server.d.ts.map +1 -0
- package/dist/errors/server.js +49 -0
- package/dist/errors/server.js.map +1 -0
- package/dist/errors/session.d.ts +16 -0
- package/dist/errors/session.d.ts.map +1 -0
- package/dist/errors/session.js +11 -0
- package/dist/errors/session.js.map +1 -0
- package/dist/errors/transport.d.ts +42 -0
- package/dist/errors/transport.d.ts.map +1 -0
- package/dist/errors/transport.js +49 -0
- package/dist/errors/transport.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/platform.d.ts +12 -0
- package/dist/internal/platform.d.ts.map +1 -0
- package/dist/internal/platform.js +11 -0
- package/dist/internal/platform.js.map +1 -0
- package/dist/internal/singleFlight.d.ts +29 -0
- package/dist/internal/singleFlight.d.ts.map +1 -0
- package/dist/internal/singleFlight.js +41 -0
- package/dist/internal/singleFlight.js.map +1 -0
- package/dist/profiles.d.ts +179 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +191 -0
- package/dist/profiles.js.map +1 -0
- package/dist/protocol/jsonrpc.d.ts +127 -0
- package/dist/protocol/jsonrpc.d.ts.map +1 -0
- package/dist/protocol/jsonrpc.js +117 -0
- package/dist/protocol/jsonrpc.js.map +1 -0
- package/dist/records/index.d.ts +14 -0
- package/dist/records/index.d.ts.map +1 -0
- package/dist/records/index.js +14 -0
- package/dist/records/index.js.map +1 -0
- package/dist/records/recordModel.d.ts +131 -0
- package/dist/records/recordModel.d.ts.map +1 -0
- package/dist/records/recordModel.js +101 -0
- package/dist/records/recordModel.js.map +1 -0
- package/dist/records/related.d.ts +42 -0
- package/dist/records/related.d.ts.map +1 -0
- package/dist/records/related.js +51 -0
- package/dist/records/related.js.map +1 -0
- package/dist/records/relations.d.ts +66 -0
- package/dist/records/relations.d.ts.map +1 -0
- package/dist/records/relations.js +119 -0
- package/dist/records/relations.js.map +1 -0
- package/dist/records/spec.d.ts +44 -0
- package/dist/records/spec.d.ts.map +1 -0
- package/dist/records/spec.js +63 -0
- package/dist/records/spec.js.map +1 -0
- package/dist/records/typed.d.ts +71 -0
- package/dist/records/typed.d.ts.map +1 -0
- package/dist/records/typed.js +65 -0
- package/dist/records/typed.js.map +1 -0
- package/dist/rpc.d.ts +90 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +113 -0
- package/dist/rpc.js.map +1 -0
- package/dist/services/db.d.ts +128 -0
- package/dist/services/db.d.ts.map +1 -0
- package/dist/services/db.js +132 -0
- package/dist/services/db.js.map +1 -0
- package/dist/services/report.d.ts +48 -0
- package/dist/services/report.d.ts.map +1 -0
- package/dist/services/report.js +103 -0
- package/dist/services/report.js.map +1 -0
- package/dist/session/cookie.d.ts +114 -0
- package/dist/session/cookie.d.ts.map +1 -0
- package/dist/session/cookie.js +231 -0
- package/dist/session/cookie.js.map +1 -0
- package/dist/testing/fakeTransport.d.ts +40 -0
- package/dist/testing/fakeTransport.d.ts.map +1 -0
- package/dist/testing/fakeTransport.js +36 -0
- package/dist/testing/fakeTransport.js.map +1 -0
- package/dist/testing/index.d.ts +9 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/transport.d.ts +62 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +9 -0
- package/dist/transport.js.map +1 -0
- package/dist/transports/json2.d.ts +41 -0
- package/dist/transports/json2.d.ts.map +1 -0
- package/dist/transports/json2.js +196 -0
- package/dist/transports/json2.js.map +1 -0
- package/dist/transports/jsonrpc.d.ts +41 -0
- package/dist/transports/jsonrpc.d.ts.map +1 -0
- package/dist/transports/jsonrpc.js +119 -0
- package/dist/transports/jsonrpc.js.map +1 -0
- package/dist/transports/web.d.ts +15 -0
- package/dist/transports/web.d.ts.map +1 -0
- package/dist/transports/web.js +62 -0
- package/dist/transports/web.js.map +1 -0
- package/dist/version-live.d.ts +41 -0
- package/dist/version-live.d.ts.map +1 -0
- package/dist/version-live.js +26 -0
- package/dist/version-live.js.map +1 -0
- package/dist/version.d.ts +78 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +46 -0
- package/dist/version.js.map +1 -0
- package/docs/protocol-verification.md +43 -0
- package/package.json +71 -0
- package/src/client.ts +731 -0
- package/src/combinators/retryOnSessionExpired.ts +25 -0
- package/src/commands.ts +112 -0
- package/src/config.ts +110 -0
- package/src/domain.ts +91 -0
- package/src/errors/auth.ts +28 -0
- package/src/errors/index.ts +50 -0
- package/src/errors/mapFault.ts +92 -0
- package/src/errors/protocol.ts +19 -0
- package/src/errors/schema.ts +18 -0
- package/src/errors/server.ts +79 -0
- package/src/errors/session.ts +12 -0
- package/src/errors/transport.ts +74 -0
- package/src/index.ts +152 -0
- package/src/internal/platform.ts +22 -0
- package/src/internal/singleFlight.ts +80 -0
- package/src/profiles.ts +386 -0
- package/src/protocol/jsonrpc.ts +162 -0
- package/src/records/index.ts +49 -0
- package/src/records/recordModel.ts +211 -0
- package/src/records/related.ts +80 -0
- package/src/records/relations.ts +192 -0
- package/src/records/spec.ts +96 -0
- package/src/records/typed.ts +180 -0
- package/src/rpc.ts +219 -0
- package/src/services/db.ts +282 -0
- package/src/services/report.ts +165 -0
- package/src/session/cookie.ts +461 -0
- package/src/testing/fakeTransport.ts +84 -0
- package/src/testing/index.ts +9 -0
- package/src/transport.ts +72 -0
- package/src/transports/json2.ts +303 -0
- package/src/transports/jsonrpc.ts +218 -0
- package/src/transports/web.ts +96 -0
- package/src/version-live.ts +67 -0
- package/src/version.ts +102 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
2
|
+
import type { Domain } from "./domain.ts";
|
|
3
|
+
import { normalizeDomain } from "./domain.ts";
|
|
4
|
+
import { ProtocolUnsupportedError } from "./errors/protocol.ts";
|
|
5
|
+
import { SchemaDriftError } from "./errors/schema.ts";
|
|
6
|
+
import { OdooMissingError, type OdooServerError } from "./errors/server.ts";
|
|
7
|
+
import {
|
|
8
|
+
compileSpecification,
|
|
9
|
+
type HasId,
|
|
10
|
+
makeTypedRecordSet,
|
|
11
|
+
type RecordSpec,
|
|
12
|
+
type TypedRecordSet,
|
|
13
|
+
} from "./records/index.ts";
|
|
14
|
+
import { type OdooContext, Rpc } from "./rpc.ts";
|
|
15
|
+
import type { TransportCallError } from "./transport.ts";
|
|
16
|
+
import { VersionResolver } from "./version.ts";
|
|
17
|
+
|
|
18
|
+
/** A decoded Odoo record with unmodeled field values. */
|
|
19
|
+
export type OdooRecord = { readonly [field: string]: unknown };
|
|
20
|
+
|
|
21
|
+
/** `fields_get` metadata: field name → its attribute dict. */
|
|
22
|
+
export type FieldsMetadata = { readonly [field: string]: OdooRecord };
|
|
23
|
+
|
|
24
|
+
const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown);
|
|
25
|
+
const RecordArray = Schema.Array(UnknownRecord);
|
|
26
|
+
const IdArray = Schema.Array(Schema.Number);
|
|
27
|
+
const FieldsGetResult = Schema.Record(Schema.String, UnknownRecord);
|
|
28
|
+
const TrueLiteral = Schema.Literal(true);
|
|
29
|
+
/** A `(id, display_name)` pair, as returned by `name_search`/`name_get`. */
|
|
30
|
+
const NamePair = Schema.Tuple([Schema.Number, Schema.String]);
|
|
31
|
+
const NamePairArray = Schema.Array(NamePair);
|
|
32
|
+
/** `check_object_reference` → `(model, res_id)`; `res_id` is `false` when the
|
|
33
|
+
* xml_id resolves but is not visible to the caller. */
|
|
34
|
+
const ObjectReference = Schema.Tuple([
|
|
35
|
+
Schema.String,
|
|
36
|
+
Schema.Union([Schema.Number, Schema.Literal(false)]),
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/** Options for {@link OdooClient.call} — the undecoded model-method escape hatch. */
|
|
40
|
+
export interface CallOptions {
|
|
41
|
+
readonly ids?: ReadonlyArray<number>;
|
|
42
|
+
readonly context?: OdooContext;
|
|
43
|
+
readonly kwargs?: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Arguments for `read_group` (all Python-stable parameter names, 16–19). */
|
|
47
|
+
export interface ReadGroupOptions {
|
|
48
|
+
readonly domain: Domain;
|
|
49
|
+
readonly fields: ReadonlyArray<string>;
|
|
50
|
+
readonly groupby: ReadonlyArray<string>;
|
|
51
|
+
readonly limit?: number;
|
|
52
|
+
readonly offset?: number;
|
|
53
|
+
readonly orderby?: string;
|
|
54
|
+
readonly lazy?: boolean;
|
|
55
|
+
readonly context?: OdooContext;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Arguments for `name_search`. `args` is the optional search domain. */
|
|
59
|
+
export interface NameSearchOptions {
|
|
60
|
+
readonly name?: string;
|
|
61
|
+
readonly args?: Domain;
|
|
62
|
+
readonly operator?: string;
|
|
63
|
+
readonly limit?: number;
|
|
64
|
+
readonly context?: OdooContext;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Options shared by the record-listing ops. `context` merges via {@link Rpc}. */
|
|
68
|
+
export interface SearchOptions {
|
|
69
|
+
readonly domain?: Domain;
|
|
70
|
+
readonly limit?: number;
|
|
71
|
+
readonly offset?: number;
|
|
72
|
+
readonly order?: string;
|
|
73
|
+
readonly context?: OdooContext;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SearchReadOptions extends SearchOptions {
|
|
77
|
+
readonly fields?: ReadonlyArray<string>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Options for the declared-prefetch typed reads ({@link OdooClient.searchTyped}).
|
|
82
|
+
* `serverMajor` is the explicit version-gate override used when NO
|
|
83
|
+
* {@link VersionResolver} is in scope: it decides whether the `specification`
|
|
84
|
+
* path is taken (>= 17) or, for a relation-free spec, degrades to `search_read`
|
|
85
|
+
* (< 17). When a `VersionResolver` IS provided it always wins and `serverMajor`
|
|
86
|
+
* is ignored.
|
|
87
|
+
*/
|
|
88
|
+
export interface TypedOptions extends SearchOptions {
|
|
89
|
+
readonly serverMajor?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Options for {@link OdooClient.readTyped}/`saveTyped` — context + the same
|
|
93
|
+
* explicit `serverMajor` gate override as {@link TypedOptions}. */
|
|
94
|
+
export interface TypedReadOptions {
|
|
95
|
+
readonly context?: OdooContext;
|
|
96
|
+
readonly serverMajor?: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `web_search_read` returns `{ length, records }`, not a bare list. */
|
|
100
|
+
const WebSearchReadResult = <A, I>(
|
|
101
|
+
row: Schema.Codec<A, I>,
|
|
102
|
+
): Schema.Codec<{ readonly length: number; readonly records: ReadonlyArray<A> }, unknown> =>
|
|
103
|
+
Schema.Struct({ length: Schema.Number, records: Schema.Array(row) }) as unknown as Schema.Codec<
|
|
104
|
+
{ readonly length: number; readonly records: ReadonlyArray<A> },
|
|
105
|
+
unknown
|
|
106
|
+
>;
|
|
107
|
+
|
|
108
|
+
/** Drop `undefined`-valued keys so we never wire an explicit `limit: null`. */
|
|
109
|
+
const compact = (obj: Record<string, unknown>): Record<string, unknown> => {
|
|
110
|
+
const out: Record<string, unknown> = {};
|
|
111
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
112
|
+
if (value !== undefined) {
|
|
113
|
+
out[key] = value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const decode =
|
|
120
|
+
<A, I>(schema: Schema.Codec<A, I>, context: string) =>
|
|
121
|
+
(raw: unknown): Effect.Effect<A, SchemaDriftError> =>
|
|
122
|
+
Schema.decodeUnknownEffect(schema)(raw).pipe(
|
|
123
|
+
Effect.mapError((cause) => new SchemaDriftError({ context, payload: raw, cause })),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Hand-typed, boundary-decoded high-level Odoo operations over {@link Rpc}.
|
|
128
|
+
* Every result is decoded through `effect/Schema`; a wire-shape mismatch fails
|
|
129
|
+
* with {@link SchemaDriftError} carrying the raw payload, never a silent cast.
|
|
130
|
+
*/
|
|
131
|
+
export class OdooClient extends Context.Service<
|
|
132
|
+
OdooClient,
|
|
133
|
+
{
|
|
134
|
+
/**
|
|
135
|
+
* `search_read(domain, fields, offset, limit, order)` — search and read in
|
|
136
|
+
* one round trip. Rows decode through `schema` (default: unknown record).
|
|
137
|
+
*/
|
|
138
|
+
readonly searchRead: <A = OdooRecord, I = A>(
|
|
139
|
+
model: string,
|
|
140
|
+
options?: SearchReadOptions,
|
|
141
|
+
schema?: Schema.Codec<A, I>,
|
|
142
|
+
) => Effect.Effect<ReadonlyArray<A>, TransportCallError>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `search_read`, decoding rows through `schema`, into a {@link TypedRecordSet}
|
|
146
|
+
* — an immutable snapshot bound to `(rpc, model)` with explicit, batched
|
|
147
|
+
* relation traversal (`fetchRelated`/`joinRelated`). The schema MUST decode
|
|
148
|
+
* an `id: number` on every row (`A extends { id: number }`); that id keys all
|
|
149
|
+
* downstream joins. This is the classic-tier ("explicit traversal",
|
|
150
|
+
* `[id, name]`-pair protocol, works on 16+) entry
|
|
151
|
+
* point — one `search_read` here, then one `read` per relation you traverse.
|
|
152
|
+
*/
|
|
153
|
+
readonly searchRecordsTyped: <A extends HasId, I = A>(
|
|
154
|
+
model: string,
|
|
155
|
+
options: SearchReadOptions | undefined,
|
|
156
|
+
schema: Schema.Codec<A, I>,
|
|
157
|
+
) => Effect.Effect<TypedRecordSet<A>, TransportCallError>;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Spec-tier typed search (declared prefetch, 17+ `specification`
|
|
161
|
+
* protocol). Compiles `record`'s field
|
|
162
|
+
* graph into ONE `web_search_read` `specification` call and strict-decodes
|
|
163
|
+
* the nested payload — a declared many2one comes back as a `{ id, ... }` dict
|
|
164
|
+
* (or `null`), an x2many as a nested list — with NO per-relation round trip.
|
|
165
|
+
*
|
|
166
|
+
* VERSION GATE (before any round trip). If a {@link VersionResolver} is in
|
|
167
|
+
* scope it decides; else `options.serverMajor` does; else the spec path is
|
|
168
|
+
* assumed (17+). A declared relation on a < 17 server fails with
|
|
169
|
+
* {@link ProtocolUnsupportedError}; a relation-free declared model degrades to
|
|
170
|
+
* a plain `search_read` (one trip, identical decode).
|
|
171
|
+
*/
|
|
172
|
+
readonly searchTyped: <A extends HasId, I = A>(
|
|
173
|
+
record: RecordSpec<A, I>,
|
|
174
|
+
options?: TypedOptions,
|
|
175
|
+
) => Effect.Effect<ReadonlyArray<A>, TransportCallError>;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Declared-prefetch typed read by ids via `web_read` (17+). Same nested
|
|
179
|
+
* decode and version gate as {@link searchTyped}; degrades to plain `read`
|
|
180
|
+
* for a relation-free spec on < 17.
|
|
181
|
+
*/
|
|
182
|
+
readonly readTyped: <A extends HasId, I = A>(
|
|
183
|
+
record: RecordSpec<A, I>,
|
|
184
|
+
ids: ReadonlyArray<number>,
|
|
185
|
+
options?: TypedReadOptions,
|
|
186
|
+
) => Effect.Effect<ReadonlyArray<A>, TransportCallError>;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Write `values` to `ids` and return the FRESH nested snapshot via
|
|
190
|
+
* `web_save` (17+ ONLY — no degrade, even for a relation-free spec). One
|
|
191
|
+
* explicit call: it writes, then re-reads through `record`'s compiled
|
|
192
|
+
* `specification`. On < 17 fails with {@link ProtocolUnsupportedError}.
|
|
193
|
+
*/
|
|
194
|
+
readonly saveTyped: <A extends HasId, I = A>(
|
|
195
|
+
record: RecordSpec<A, I>,
|
|
196
|
+
ids: ReadonlyArray<number>,
|
|
197
|
+
values: OdooRecord,
|
|
198
|
+
options?: TypedReadOptions,
|
|
199
|
+
) => Effect.Effect<ReadonlyArray<A>, TransportCallError>;
|
|
200
|
+
|
|
201
|
+
/** `search(domain, offset, limit, order)` — matching record ids. */
|
|
202
|
+
readonly search: (
|
|
203
|
+
model: string,
|
|
204
|
+
options?: SearchOptions,
|
|
205
|
+
) => Effect.Effect<ReadonlyArray<number>, TransportCallError>;
|
|
206
|
+
|
|
207
|
+
/** `read(ids, fields)` — read fields of the given records. */
|
|
208
|
+
readonly read: (
|
|
209
|
+
model: string,
|
|
210
|
+
ids: ReadonlyArray<number>,
|
|
211
|
+
fields?: ReadonlyArray<string>,
|
|
212
|
+
) => Effect.Effect<ReadonlyArray<OdooRecord>, TransportCallError>;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `create(vals_list)` — create one or many records, always returning the new
|
|
216
|
+
* ids as an array (even for a single dict, which is normalized to a
|
|
217
|
+
* one-element list). Odoo 16–19 `model_create_multi` returns a list of ids
|
|
218
|
+
* for a list input, so the return contract is uniformly {@link ReadonlyArray}.
|
|
219
|
+
*/
|
|
220
|
+
readonly create: (
|
|
221
|
+
model: string,
|
|
222
|
+
values: OdooRecord | ReadonlyArray<OdooRecord>,
|
|
223
|
+
) => Effect.Effect<ReadonlyArray<number>, TransportCallError>;
|
|
224
|
+
|
|
225
|
+
/** `write(ids, values)` — update records; Odoo returns `true`. */
|
|
226
|
+
readonly write: (
|
|
227
|
+
model: string,
|
|
228
|
+
ids: ReadonlyArray<number>,
|
|
229
|
+
values: OdooRecord,
|
|
230
|
+
) => Effect.Effect<true, TransportCallError>;
|
|
231
|
+
|
|
232
|
+
/** `unlink(ids)` — delete records; Odoo returns `true`. */
|
|
233
|
+
readonly unlink: (
|
|
234
|
+
model: string,
|
|
235
|
+
ids: ReadonlyArray<number>,
|
|
236
|
+
) => Effect.Effect<true, TransportCallError>;
|
|
237
|
+
|
|
238
|
+
/** `fields_get(attributes=...)` — field metadata keyed by field name. */
|
|
239
|
+
readonly fieldsGet: (
|
|
240
|
+
model: string,
|
|
241
|
+
options?: { readonly attributes?: ReadonlyArray<string> },
|
|
242
|
+
) => Effect.Effect<FieldsMetadata, TransportCallError>;
|
|
243
|
+
|
|
244
|
+
/** `search_count(domain)` — number of records matching the domain. */
|
|
245
|
+
readonly searchCount: (
|
|
246
|
+
model: string,
|
|
247
|
+
domain: Domain,
|
|
248
|
+
) => Effect.Effect<number, TransportCallError>;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Resolve an external identifier (`module.name`) to its `(model, id)` pair,
|
|
252
|
+
* via `ir.model.data.check_object_reference`.
|
|
253
|
+
*
|
|
254
|
+
* We always use `check_object_reference` (present since Odoo 15) rather than
|
|
255
|
+
* branching to the legacy `xmlid_to_res_model_res_id` — this library
|
|
256
|
+
* supports 16–19 only, so the legacy branch is dead weight. A missing xml_id
|
|
257
|
+
* (the server raises a `ValueError`, mapped to `OdooServerError`) or an
|
|
258
|
+
* xml_id that resolves but is not visible to the caller (`[model, false]`)
|
|
259
|
+
* both fail with {@link OdooMissingError} naming the xml_id.
|
|
260
|
+
*/
|
|
261
|
+
readonly ref: (
|
|
262
|
+
xmlId: string,
|
|
263
|
+
) => Effect.Effect<readonly [model: string, id: number], TransportCallError>;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Escape hatch: call any `model.method` and return its raw, UNDECODED result.
|
|
267
|
+
* The browse `ids`, `context`, and `kwargs` ride the {@link Rpc} seam; no
|
|
268
|
+
* schema is applied, so the caller owns validating the shape. Prefer the
|
|
269
|
+
* typed ops above where one exists.
|
|
270
|
+
*/
|
|
271
|
+
readonly call: (
|
|
272
|
+
model: string,
|
|
273
|
+
method: string,
|
|
274
|
+
options?: CallOptions,
|
|
275
|
+
) => Effect.Effect<unknown, TransportCallError>;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* `read_group(domain, fields, groupby, offset, limit, orderby, lazy)` —
|
|
279
|
+
* aggregate records into groups. Rows decode as unknown records (group keys,
|
|
280
|
+
* aggregates, and `__count`/`__domain` are model-dependent).
|
|
281
|
+
*/
|
|
282
|
+
readonly readGroup: (
|
|
283
|
+
model: string,
|
|
284
|
+
options: ReadGroupOptions,
|
|
285
|
+
) => Effect.Effect<ReadonlyArray<OdooRecord>, TransportCallError>;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* `name_search(name, domain, operator, limit)` → `(id, display_name)` pairs.
|
|
289
|
+
*
|
|
290
|
+
* WIRE FACT: the second parameter was renamed `args` → `domain` in Odoo 19.
|
|
291
|
+
* Over the execute_kw family we pass all four POSITIONALLY (position is
|
|
292
|
+
* stable across 16–19); over JSON-2 (19+) we pass them by MODERN name
|
|
293
|
+
* (`name`, `domain`, `operator`, `limit`).
|
|
294
|
+
*/
|
|
295
|
+
readonly nameSearch: (
|
|
296
|
+
model: string,
|
|
297
|
+
options?: NameSearchOptions,
|
|
298
|
+
) => Effect.Effect<ReadonlyArray<readonly [number, string]>, TransportCallError>;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* `name_get()` → `(id, display_name)` pairs for the given ids.
|
|
302
|
+
*
|
|
303
|
+
* GOTCHA: `name_get` was REMOVED server-side in Odoo 17+. We call it as-is
|
|
304
|
+
* with no polyfill; on 17+ it fails as a server fault. Use {@link nameSearch}
|
|
305
|
+
* or read `display_name` instead on modern servers.
|
|
306
|
+
*/
|
|
307
|
+
readonly nameGet: (
|
|
308
|
+
model: string,
|
|
309
|
+
ids: ReadonlyArray<number>,
|
|
310
|
+
) => Effect.Effect<ReadonlyArray<readonly [number, string]>, TransportCallError>;
|
|
311
|
+
}
|
|
312
|
+
>()("odoo-rpc-ts/OdooClient") {}
|
|
313
|
+
|
|
314
|
+
/** The {@link OdooClient} layer over an {@link Rpc} service. */
|
|
315
|
+
export const layer: Layer.Layer<OdooClient, never, Rpc> = Layer.effect(
|
|
316
|
+
OdooClient,
|
|
317
|
+
Effect.gen(function* () {
|
|
318
|
+
const rpc = yield* Rpc;
|
|
319
|
+
|
|
320
|
+
const withContext = (context?: OdooContext) =>
|
|
321
|
+
context === undefined ? undefined : { context };
|
|
322
|
+
|
|
323
|
+
const searchRead = <A = OdooRecord, I = A>(
|
|
324
|
+
model: string,
|
|
325
|
+
options?: SearchReadOptions,
|
|
326
|
+
schema?: Schema.Codec<A, I>,
|
|
327
|
+
): Effect.Effect<ReadonlyArray<A>, TransportCallError> => {
|
|
328
|
+
// Rows decode FROM the wire shape (I) TO the domain shape (A), so
|
|
329
|
+
// transforming schemas (DateFromString, false->null, ...) are first-class.
|
|
330
|
+
const rowSchema = (schema ?? UnknownRecord) as Schema.Codec<A, I>;
|
|
331
|
+
const kwargs = compact({
|
|
332
|
+
domain: normalizeDomain(options?.domain ?? []),
|
|
333
|
+
fields: options?.fields,
|
|
334
|
+
limit: options?.limit,
|
|
335
|
+
offset: options?.offset,
|
|
336
|
+
order: options?.order,
|
|
337
|
+
});
|
|
338
|
+
return rpc
|
|
339
|
+
.callKw(model, "search_read", [], kwargs, withContext(options?.context))
|
|
340
|
+
.pipe(Effect.flatMap(decode(Schema.Array(rowSchema), `${model}.search_read`)));
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const searchRecordsTyped = <A extends HasId, I = A>(
|
|
344
|
+
model: string,
|
|
345
|
+
options: SearchReadOptions | undefined,
|
|
346
|
+
schema: Schema.Codec<A, I>,
|
|
347
|
+
): Effect.Effect<TypedRecordSet<A>, TransportCallError> =>
|
|
348
|
+
// Reuse the search_read decode plumbing, then wrap the decoded rows in a
|
|
349
|
+
// snapshot bound to this Rpc seam so fetchRelated can batch a co-model read.
|
|
350
|
+
searchRead(model, options, schema).pipe(
|
|
351
|
+
Effect.map((rows) => makeTypedRecordSet(rpc, model, rows)),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
// --- spec-tier typed reads (17+ `specification` protocol) ---------------
|
|
355
|
+
|
|
356
|
+
type GateDecision =
|
|
357
|
+
| { readonly _tag: "spec" }
|
|
358
|
+
| { readonly _tag: "degrade" }
|
|
359
|
+
| { readonly _tag: "unsupported"; readonly serverVersion: string };
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Resolve whether the `specification` path is available. A {@link VersionResolver}
|
|
363
|
+
* in scope wins (its capability is authoritative); absent, an explicit
|
|
364
|
+
* `serverMajor` gates; absent both, we DEFAULT to the spec path (17+
|
|
365
|
+
* assumed) — documented on {@link TypedOptions.serverMajor}, and a 16 server
|
|
366
|
+
* then surfaces the server's own fault through the choke point.
|
|
367
|
+
*/
|
|
368
|
+
const resolveSupportsSpec = (
|
|
369
|
+
serverMajor: number | undefined,
|
|
370
|
+
): Effect.Effect<{ readonly supports: boolean; readonly label: string }, TransportCallError> =>
|
|
371
|
+
Effect.serviceOption(VersionResolver).pipe(
|
|
372
|
+
Effect.flatMap((opt) =>
|
|
373
|
+
Option.match(opt, {
|
|
374
|
+
onSome: (vr) =>
|
|
375
|
+
vr.resolve.pipe(
|
|
376
|
+
Effect.map((r) => ({
|
|
377
|
+
supports: r.capabilities.supportsWebReadSpec,
|
|
378
|
+
label: r.version.raw.join("."),
|
|
379
|
+
})),
|
|
380
|
+
),
|
|
381
|
+
onNone: () =>
|
|
382
|
+
serverMajor === undefined
|
|
383
|
+
? Effect.succeed({ supports: true, label: "unknown (assumed 17+)" })
|
|
384
|
+
: Effect.succeed({ supports: serverMajor >= 17, label: `${serverMajor}.x` }),
|
|
385
|
+
}),
|
|
386
|
+
),
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
/** `requireSpec` (saveTyped) forbids the degrade branch even for a
|
|
390
|
+
* relation-free spec — `web_save` is 17+ only. */
|
|
391
|
+
const gateSpec = (
|
|
392
|
+
hasRelations: boolean,
|
|
393
|
+
requireSpec: boolean,
|
|
394
|
+
serverMajor: number | undefined,
|
|
395
|
+
): Effect.Effect<GateDecision, TransportCallError> =>
|
|
396
|
+
resolveSupportsSpec(serverMajor).pipe(
|
|
397
|
+
Effect.map(({ supports, label }): GateDecision => {
|
|
398
|
+
if (supports) {
|
|
399
|
+
return { _tag: "spec" };
|
|
400
|
+
}
|
|
401
|
+
if (requireSpec || hasRelations) {
|
|
402
|
+
return { _tag: "unsupported", serverVersion: label };
|
|
403
|
+
}
|
|
404
|
+
return { _tag: "degrade" };
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
const specUnsupported = (
|
|
409
|
+
model: string,
|
|
410
|
+
method: string,
|
|
411
|
+
serverVersion: string,
|
|
412
|
+
): ProtocolUnsupportedError =>
|
|
413
|
+
new ProtocolUnsupportedError({
|
|
414
|
+
protocol: "web",
|
|
415
|
+
serverVersion,
|
|
416
|
+
message:
|
|
417
|
+
`${model}.${method}: the web_read 'specification' protocol (declared-prefetch typed ` +
|
|
418
|
+
`records) requires Odoo 17+, but the server is ${serverVersion}. For a relation-free ` +
|
|
419
|
+
`read on 16 use searchTyped/readTyped (they degrade to search_read/read); for ` +
|
|
420
|
+
`relation traversal on 16 use searchRecordsTyped + fetchRelated (the classic tier).`,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const declaredFields = <A extends HasId, I>(record: RecordSpec<A, I>): ReadonlyArray<string> =>
|
|
424
|
+
Object.keys(record.fields);
|
|
425
|
+
|
|
426
|
+
const searchTyped = <A extends HasId, I = A>(
|
|
427
|
+
record: RecordSpec<A, I>,
|
|
428
|
+
options?: TypedOptions,
|
|
429
|
+
): Effect.Effect<ReadonlyArray<A>, TransportCallError> =>
|
|
430
|
+
gateSpec(record.hasRelations, false, options?.serverMajor).pipe(
|
|
431
|
+
Effect.flatMap((gate) => {
|
|
432
|
+
if (gate._tag === "unsupported") {
|
|
433
|
+
return Effect.fail(
|
|
434
|
+
specUnsupported(record.model, "web_search_read", gate.serverVersion),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
if (gate._tag === "degrade") {
|
|
438
|
+
// Relation-free on < 17: plain search_read over the declared fields;
|
|
439
|
+
// the row schema decodes both wire shapes identically.
|
|
440
|
+
return searchRead(
|
|
441
|
+
record.model,
|
|
442
|
+
{ ...options, fields: declaredFields(record) },
|
|
443
|
+
record.schema,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
return Effect.sync(() => compileSpecification(record)).pipe(
|
|
447
|
+
Effect.flatMap((specification) => {
|
|
448
|
+
const kwargs = compact({
|
|
449
|
+
specification,
|
|
450
|
+
domain: normalizeDomain(options?.domain ?? []),
|
|
451
|
+
limit: options?.limit,
|
|
452
|
+
offset: options?.offset,
|
|
453
|
+
order: options?.order,
|
|
454
|
+
});
|
|
455
|
+
return rpc.callKw(
|
|
456
|
+
record.model,
|
|
457
|
+
"web_search_read",
|
|
458
|
+
[],
|
|
459
|
+
kwargs,
|
|
460
|
+
withContext(options?.context),
|
|
461
|
+
);
|
|
462
|
+
}),
|
|
463
|
+
Effect.flatMap(
|
|
464
|
+
decode(WebSearchReadResult(record.schema), `${record.model}.web_search_read`),
|
|
465
|
+
),
|
|
466
|
+
Effect.map((result) => result.records),
|
|
467
|
+
);
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const readTyped = <A extends HasId, I = A>(
|
|
472
|
+
record: RecordSpec<A, I>,
|
|
473
|
+
ids: ReadonlyArray<number>,
|
|
474
|
+
options?: TypedReadOptions,
|
|
475
|
+
): Effect.Effect<ReadonlyArray<A>, TransportCallError> =>
|
|
476
|
+
gateSpec(record.hasRelations, false, options?.serverMajor).pipe(
|
|
477
|
+
Effect.flatMap((gate) => {
|
|
478
|
+
if (gate._tag === "unsupported") {
|
|
479
|
+
return Effect.fail(specUnsupported(record.model, "web_read", gate.serverVersion));
|
|
480
|
+
}
|
|
481
|
+
const seam = {
|
|
482
|
+
ids,
|
|
483
|
+
...(options?.context !== undefined ? { context: options.context } : {}),
|
|
484
|
+
};
|
|
485
|
+
if (gate._tag === "degrade") {
|
|
486
|
+
return rpc
|
|
487
|
+
.callKw(record.model, "read", [], { fields: declaredFields(record) }, seam)
|
|
488
|
+
.pipe(Effect.flatMap(decode(Schema.Array(record.schema), `${record.model}.read`)));
|
|
489
|
+
}
|
|
490
|
+
return Effect.sync(() => compileSpecification(record)).pipe(
|
|
491
|
+
Effect.flatMap((specification) =>
|
|
492
|
+
rpc.callKw(record.model, "web_read", [], { specification }, seam),
|
|
493
|
+
),
|
|
494
|
+
Effect.flatMap(decode(Schema.Array(record.schema), `${record.model}.web_read`)),
|
|
495
|
+
);
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const saveTyped = <A extends HasId, I = A>(
|
|
500
|
+
record: RecordSpec<A, I>,
|
|
501
|
+
ids: ReadonlyArray<number>,
|
|
502
|
+
values: OdooRecord,
|
|
503
|
+
options?: TypedReadOptions,
|
|
504
|
+
): Effect.Effect<ReadonlyArray<A>, TransportCallError> =>
|
|
505
|
+
gateSpec(record.hasRelations, true, options?.serverMajor).pipe(
|
|
506
|
+
Effect.flatMap((gate) => {
|
|
507
|
+
if (gate._tag === "unsupported") {
|
|
508
|
+
return Effect.fail(specUnsupported(record.model, "web_save", gate.serverVersion));
|
|
509
|
+
}
|
|
510
|
+
const seam = {
|
|
511
|
+
ids,
|
|
512
|
+
...(options?.context !== undefined ? { context: options.context } : {}),
|
|
513
|
+
};
|
|
514
|
+
return Effect.sync(() => compileSpecification(record)).pipe(
|
|
515
|
+
Effect.flatMap((specification) =>
|
|
516
|
+
// vals is positional over execute_kw (call_kw reads args[0]) and
|
|
517
|
+
// keyword-only (`vals`) over JSON-2 — mirror create's dialect split.
|
|
518
|
+
rpc.dialect === "json2"
|
|
519
|
+
? rpc.callKw(record.model, "web_save", [], { vals: values, specification }, seam)
|
|
520
|
+
: rpc.callKw(record.model, "web_save", [values], { specification }, seam),
|
|
521
|
+
),
|
|
522
|
+
Effect.flatMap(decode(Schema.Array(record.schema), `${record.model}.web_save`)),
|
|
523
|
+
);
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
const search = (
|
|
528
|
+
model: string,
|
|
529
|
+
options?: SearchOptions,
|
|
530
|
+
): Effect.Effect<ReadonlyArray<number>, TransportCallError> => {
|
|
531
|
+
const kwargs = compact({
|
|
532
|
+
domain: normalizeDomain(options?.domain ?? []),
|
|
533
|
+
limit: options?.limit,
|
|
534
|
+
offset: options?.offset,
|
|
535
|
+
order: options?.order,
|
|
536
|
+
});
|
|
537
|
+
return rpc
|
|
538
|
+
.callKw(model, "search", [], kwargs, withContext(options?.context))
|
|
539
|
+
.pipe(Effect.flatMap(decode(IdArray, `${model}.search`)));
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// The record-targeting ops are protocol-agnostic: the browse ids ride on the
|
|
543
|
+
// transport seam's `ids` (positional on execute_kw, the `ids` body key on
|
|
544
|
+
// JSON-2), and every other argument is passed by its stable Python parameter
|
|
545
|
+
// name (`fields`, `vals`, `vals_list`, `domain`) — `call_kw` binds kwargs by
|
|
546
|
+
// name identically across Odoo 16–19 and all three transports.
|
|
547
|
+
const read = (
|
|
548
|
+
model: string,
|
|
549
|
+
ids: ReadonlyArray<number>,
|
|
550
|
+
fields?: ReadonlyArray<string>,
|
|
551
|
+
): Effect.Effect<ReadonlyArray<OdooRecord>, TransportCallError> =>
|
|
552
|
+
rpc
|
|
553
|
+
.callKw(model, "read", [], compact({ fields }), { ids })
|
|
554
|
+
.pipe(Effect.flatMap(decode(RecordArray, `${model}.read`)));
|
|
555
|
+
|
|
556
|
+
const create = (
|
|
557
|
+
model: string,
|
|
558
|
+
values: OdooRecord | ReadonlyArray<OdooRecord>,
|
|
559
|
+
): Effect.Effect<ReadonlyArray<number>, TransportCallError> => {
|
|
560
|
+
const valsList = Array.isArray(values) ? values : [values];
|
|
561
|
+
// Dialect split (see TransportDialect): call_kw reads create's vals from
|
|
562
|
+
// args[0] unconditionally (16-19), while JSON-2 is keyword-only.
|
|
563
|
+
const call =
|
|
564
|
+
rpc.dialect === "json2"
|
|
565
|
+
? rpc.callKw(model, "create", [], { vals_list: valsList })
|
|
566
|
+
: rpc.callKw(model, "create", [valsList]);
|
|
567
|
+
return call.pipe(Effect.flatMap(decode(IdArray, `${model}.create`)));
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const write = (
|
|
571
|
+
model: string,
|
|
572
|
+
ids: ReadonlyArray<number>,
|
|
573
|
+
values: OdooRecord,
|
|
574
|
+
): Effect.Effect<true, TransportCallError> => {
|
|
575
|
+
// Dialect split, same reason as `create`: over execute_kw the vals MUST be
|
|
576
|
+
// positional — models override write(self, <any param name>) (e.g.
|
|
577
|
+
// project.task uses a different name), so a `vals=` kwarg breaks on real
|
|
578
|
+
// instances (verified live against Odoo 16, 2026-07-10). JSON-2 is
|
|
579
|
+
// keyword-only (the transport rejects positional args) and binds the core
|
|
580
|
+
// signature's `vals` (odoo/orm/models.py, 19.0; verified live 2026-07-10).
|
|
581
|
+
const call =
|
|
582
|
+
rpc.dialect === "json2"
|
|
583
|
+
? rpc.callKw(model, "write", [], { vals: values }, { ids })
|
|
584
|
+
: rpc.callKw(model, "write", [values], {}, { ids });
|
|
585
|
+
return call.pipe(Effect.flatMap(decode(TrueLiteral, `${model}.write`)));
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const unlink = (
|
|
589
|
+
model: string,
|
|
590
|
+
ids: ReadonlyArray<number>,
|
|
591
|
+
): Effect.Effect<true, TransportCallError> =>
|
|
592
|
+
rpc
|
|
593
|
+
.callKw(model, "unlink", [], {}, { ids })
|
|
594
|
+
.pipe(Effect.flatMap(decode(TrueLiteral, `${model}.unlink`)));
|
|
595
|
+
|
|
596
|
+
const fieldsGet = (
|
|
597
|
+
model: string,
|
|
598
|
+
options?: { readonly attributes?: ReadonlyArray<string> },
|
|
599
|
+
): Effect.Effect<FieldsMetadata, TransportCallError> =>
|
|
600
|
+
rpc
|
|
601
|
+
.callKw(model, "fields_get", [], compact({ attributes: options?.attributes }))
|
|
602
|
+
.pipe(Effect.flatMap(decode(FieldsGetResult, `${model}.fields_get`)));
|
|
603
|
+
|
|
604
|
+
const searchCount = (
|
|
605
|
+
model: string,
|
|
606
|
+
domain: Domain,
|
|
607
|
+
): Effect.Effect<number, TransportCallError> =>
|
|
608
|
+
rpc
|
|
609
|
+
.callKw(model, "search_count", [], { domain: normalizeDomain(domain) })
|
|
610
|
+
.pipe(Effect.flatMap(decode(Schema.Number, `${model}.search_count`)));
|
|
611
|
+
|
|
612
|
+
const missingRef = (xmlId: string, source?: OdooServerError): OdooMissingError =>
|
|
613
|
+
new OdooMissingError({
|
|
614
|
+
name: source?.name ?? "odoo.exceptions.MissingError",
|
|
615
|
+
message: source?.message ?? `No record found for external ID '${xmlId}'.`,
|
|
616
|
+
arguments: [xmlId],
|
|
617
|
+
context: source?.context ?? {},
|
|
618
|
+
...(source?.debug !== undefined ? { debug: source.debug } : {}),
|
|
619
|
+
model: "ir.model.data",
|
|
620
|
+
method: "check_object_reference",
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const ref = (
|
|
624
|
+
xmlId: string,
|
|
625
|
+
): Effect.Effect<readonly [model: string, id: number], TransportCallError> => {
|
|
626
|
+
const dot = xmlId.indexOf(".");
|
|
627
|
+
if (dot <= 0 || dot === xmlId.length - 1) {
|
|
628
|
+
return Effect.fail(missingRef(xmlId));
|
|
629
|
+
}
|
|
630
|
+
const module = xmlId.slice(0, dot);
|
|
631
|
+
const name = xmlId.slice(dot + 1);
|
|
632
|
+
// Dialect split: execute_kw sends (module, name) positionally; JSON-2 binds
|
|
633
|
+
// by parameter name (`module`, `xml_id`).
|
|
634
|
+
const call =
|
|
635
|
+
rpc.dialect === "json2"
|
|
636
|
+
? rpc.callKw("ir.model.data", "check_object_reference", [], { module, xml_id: name })
|
|
637
|
+
: rpc.callKw("ir.model.data", "check_object_reference", [module, name]);
|
|
638
|
+
return call.pipe(
|
|
639
|
+
// A truly-unknown xml_id makes the server raise ValueError (an unmapped
|
|
640
|
+
// fault → OdooServerError); remap it to a named OdooMissingError.
|
|
641
|
+
Effect.catchTag("OdooServerError", (e) => Effect.fail(missingRef(xmlId, e))),
|
|
642
|
+
Effect.flatMap(decode(ObjectReference, `${xmlId} check_object_reference`)),
|
|
643
|
+
Effect.flatMap(([model, resId]) =>
|
|
644
|
+
resId === false
|
|
645
|
+
? Effect.fail(missingRef(xmlId))
|
|
646
|
+
: Effect.succeed([model, resId] as const),
|
|
647
|
+
),
|
|
648
|
+
);
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const call = (
|
|
652
|
+
model: string,
|
|
653
|
+
method: string,
|
|
654
|
+
options?: CallOptions,
|
|
655
|
+
): Effect.Effect<unknown, TransportCallError> =>
|
|
656
|
+
rpc.callKw(
|
|
657
|
+
model,
|
|
658
|
+
method,
|
|
659
|
+
[],
|
|
660
|
+
options?.kwargs ?? {},
|
|
661
|
+
compact({ ids: options?.ids, context: options?.context }),
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
const readGroup = (
|
|
665
|
+
model: string,
|
|
666
|
+
options: ReadGroupOptions,
|
|
667
|
+
): Effect.Effect<ReadonlyArray<OdooRecord>, TransportCallError> => {
|
|
668
|
+
const kwargs = compact({
|
|
669
|
+
domain: normalizeDomain(options.domain),
|
|
670
|
+
fields: options.fields,
|
|
671
|
+
groupby: options.groupby,
|
|
672
|
+
limit: options.limit,
|
|
673
|
+
offset: options.offset,
|
|
674
|
+
orderby: options.orderby,
|
|
675
|
+
lazy: options.lazy,
|
|
676
|
+
});
|
|
677
|
+
return rpc
|
|
678
|
+
.callKw(model, "read_group", [], kwargs, withContext(options.context))
|
|
679
|
+
.pipe(Effect.flatMap(decode(RecordArray, `${model}.read_group`)));
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const nameSearch = (
|
|
683
|
+
model: string,
|
|
684
|
+
options?: NameSearchOptions,
|
|
685
|
+
): Effect.Effect<ReadonlyArray<readonly [number, string]>, TransportCallError> => {
|
|
686
|
+
const name = options?.name ?? "";
|
|
687
|
+
const operator = options?.operator ?? "ilike";
|
|
688
|
+
const limit = options?.limit ?? 100;
|
|
689
|
+
const domain = normalizeDomain(options?.args ?? []);
|
|
690
|
+
// Dialect split (see the interface WIRE FACT): positional over execute_kw
|
|
691
|
+
// (version-proof against the args→domain rename), by modern name on JSON-2.
|
|
692
|
+
const call_ =
|
|
693
|
+
rpc.dialect === "json2"
|
|
694
|
+
? rpc.callKw(model, "name_search", [], { name, domain, operator, limit })
|
|
695
|
+
: rpc.callKw(model, "name_search", [name, domain, operator, limit]);
|
|
696
|
+
return call_.pipe(
|
|
697
|
+
Effect.flatMap(decode(NamePairArray, `${model}.name_search`)),
|
|
698
|
+
Effect.map((pairs) => pairs.map(([id, label]) => [id, label] as const)),
|
|
699
|
+
);
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const nameGet = (
|
|
703
|
+
model: string,
|
|
704
|
+
ids: ReadonlyArray<number>,
|
|
705
|
+
): Effect.Effect<ReadonlyArray<readonly [number, string]>, TransportCallError> =>
|
|
706
|
+
rpc.callKw(model, "name_get", [], {}, { ids }).pipe(
|
|
707
|
+
Effect.flatMap(decode(NamePairArray, `${model}.name_get`)),
|
|
708
|
+
Effect.map((pairs) => pairs.map(([id, label]) => [id, label] as const)),
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
searchRead,
|
|
713
|
+
searchRecordsTyped,
|
|
714
|
+
searchTyped,
|
|
715
|
+
readTyped,
|
|
716
|
+
saveTyped,
|
|
717
|
+
search,
|
|
718
|
+
read,
|
|
719
|
+
create,
|
|
720
|
+
write,
|
|
721
|
+
unlink,
|
|
722
|
+
fieldsGet,
|
|
723
|
+
searchCount,
|
|
724
|
+
ref,
|
|
725
|
+
call,
|
|
726
|
+
readGroup,
|
|
727
|
+
nameSearch,
|
|
728
|
+
nameGet,
|
|
729
|
+
};
|
|
730
|
+
}),
|
|
731
|
+
);
|