@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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { CookieLoginError, CookieSessionService } from "../session/cookie.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Opt-in recovery for a single `SessionExpiredError`: invalidate the cached
|
|
6
|
+
* session, re-authenticate once, then retry `effect` exactly once. A second
|
|
7
|
+
* failure — including another `SessionExpiredError` — propagates untouched.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately policy-free: no backoff, no retry count beyond one. Auto-relogin
|
|
10
|
+
* is NOT wired into the transport (AGENTS.md decision); callers opt in here so
|
|
11
|
+
* the session-expiry signal stays observable by default.
|
|
12
|
+
*/
|
|
13
|
+
export const retryOnSessionExpired = <A, E, R>(
|
|
14
|
+
effect: Effect.Effect<A, E, R>,
|
|
15
|
+
session: CookieSessionService,
|
|
16
|
+
): Effect.Effect<A, E | CookieLoginError, R> =>
|
|
17
|
+
Effect.catchIf(
|
|
18
|
+
effect,
|
|
19
|
+
(error): error is Extract<E, { readonly _tag: "SessionExpiredError" }> =>
|
|
20
|
+
typeof error === "object" &&
|
|
21
|
+
error !== null &&
|
|
22
|
+
"_tag" in error &&
|
|
23
|
+
(error as { readonly _tag: unknown })._tag === "SessionExpiredError",
|
|
24
|
+
() => session.invalidate.pipe(Effect.andThen(session.login), Effect.andThen(effect)),
|
|
25
|
+
);
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed constructors for Odoo's x2many write commands — the special 3-element
|
|
3
|
+
* triples an {@link https://www.odoo.com/documentation | One2many}/Many2many
|
|
4
|
+
* field expects inside a `create`/`write` `values` payload.
|
|
5
|
+
*
|
|
6
|
+
* Each command is `[code, id, payload]`: the first element is the integer that
|
|
7
|
+
* identifies the command, the second is either the target record id (update,
|
|
8
|
+
* delete, unlink, link) or `0`, and the third is either the `values` to write
|
|
9
|
+
* (create, update), the new `ids` list (set), or `0`. Straight port of Odoo's
|
|
10
|
+
* `Command` namespace (`odoo/fields.py` 16.x, `odoo/orm/commands.py` 19.x — the
|
|
11
|
+
* emitted triples are identical across 16–19).
|
|
12
|
+
*
|
|
13
|
+
* GOTCHA: over RPC only the literal triples travel — never the Python `Command`
|
|
14
|
+
* enum members or constant names. These constructors return exactly those
|
|
15
|
+
* literal triples, so a value built here is wire-ready as-is.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Field-name → value map written by the create/update commands. */
|
|
19
|
+
export type CommandValues = { readonly [field: string]: unknown };
|
|
20
|
+
|
|
21
|
+
/** `create(values)` → `[0, 0, values]` — create a comodel record and link it. */
|
|
22
|
+
export type CreateCommand = readonly [0, 0, CommandValues];
|
|
23
|
+
|
|
24
|
+
/** `update(id, values)` → `[1, id, values]` — write `values` on the related record. */
|
|
25
|
+
export type UpdateCommand = readonly [1, number, CommandValues];
|
|
26
|
+
|
|
27
|
+
/** `delete(id)` → `[2, id, 0]` — unlink and delete the related record from the db. */
|
|
28
|
+
export type DeleteCommand = readonly [2, number, 0];
|
|
29
|
+
|
|
30
|
+
/** `unlink(id)` → `[3, id, 0]` — drop the relation, keep (or cascade) the record. */
|
|
31
|
+
export type UnlinkCommand = readonly [3, number, 0];
|
|
32
|
+
|
|
33
|
+
/** `link(id)` → `[4, id, 0]` — add a relation to an existing record. */
|
|
34
|
+
export type LinkCommand = readonly [4, number, 0];
|
|
35
|
+
|
|
36
|
+
/** `clear()` → `[5, 0, 0]` — unlink every related record. */
|
|
37
|
+
export type ClearCommand = readonly [5, 0, 0];
|
|
38
|
+
|
|
39
|
+
/** `set(ids)` → `[6, 0, ids]` — replace all relations with exactly `ids`. */
|
|
40
|
+
export type SetCommand = readonly [6, 0, ReadonlyArray<number>];
|
|
41
|
+
|
|
42
|
+
/** Any single x2many write command triple. */
|
|
43
|
+
export type CommandTuple =
|
|
44
|
+
| CreateCommand
|
|
45
|
+
| UpdateCommand
|
|
46
|
+
| DeleteCommand
|
|
47
|
+
| UnlinkCommand
|
|
48
|
+
| LinkCommand
|
|
49
|
+
| ClearCommand
|
|
50
|
+
| SetCommand;
|
|
51
|
+
|
|
52
|
+
/** A list of x2many commands, as it sits inside a `values` payload. */
|
|
53
|
+
export type X2ManyCommands = ReadonlyArray<CommandTuple>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The `Command` namespace — one constructor per x2many command. Grouped as a
|
|
57
|
+
* frozen object of standalone functions (no `enum`/`namespace`, per
|
|
58
|
+
* `erasableSyntaxOnly`); each carries the wire encoding in its return type so
|
|
59
|
+
* the literal command code survives inference.
|
|
60
|
+
*/
|
|
61
|
+
export const Command = {
|
|
62
|
+
/**
|
|
63
|
+
* Create new comodel records from `values` and link them to `self`.
|
|
64
|
+
*
|
|
65
|
+
* Wire: `[0, 0, values]`.
|
|
66
|
+
*/
|
|
67
|
+
create: (values: CommandValues): CreateCommand => [0, 0, values],
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Write `values` on the related record `id`.
|
|
71
|
+
*
|
|
72
|
+
* Wire: `[1, id, values]`.
|
|
73
|
+
*/
|
|
74
|
+
update: (id: number, values: CommandValues): UpdateCommand => [1, id, values],
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Remove the related record `id` from the database and drop its relation.
|
|
78
|
+
*
|
|
79
|
+
* Wire: `[2, id, 0]`.
|
|
80
|
+
*/
|
|
81
|
+
delete: (id: number): DeleteCommand => [2, id, 0],
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Remove the relation to record `id` without deleting it (One2many may
|
|
85
|
+
* cascade-delete when the inverse is `ondelete='cascade'`).
|
|
86
|
+
*
|
|
87
|
+
* Wire: `[3, id, 0]`.
|
|
88
|
+
*/
|
|
89
|
+
unlink: (id: number): UnlinkCommand => [3, id, 0],
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Add a relation between `self` and the existing record `id`.
|
|
93
|
+
*
|
|
94
|
+
* Wire: `[4, id, 0]`.
|
|
95
|
+
*/
|
|
96
|
+
link: (id: number): LinkCommand => [4, id, 0],
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Remove all relations (equivalent to `unlink` on every related record).
|
|
100
|
+
*
|
|
101
|
+
* Wire: `[5, 0, 0]`.
|
|
102
|
+
*/
|
|
103
|
+
clear: (): ClearCommand => [5, 0, 0],
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Replace the current relations with exactly `ids` (unlink removed, link
|
|
107
|
+
* added).
|
|
108
|
+
*
|
|
109
|
+
* Wire: `[6, 0, ids]`.
|
|
110
|
+
*/
|
|
111
|
+
set: (ids: ReadonlyArray<number>): SetCommand => [6, 0, ids],
|
|
112
|
+
} as const;
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Config, Effect, Option, Schema, SchemaIssue, type Redacted } from "effect";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How the client authenticates. A tagged union so downstream auth layers
|
|
5
|
+
* branch on `_tag` rather than probing which secret is present. Both secrets
|
|
6
|
+
* are `Redacted` — never logged, never serialized.
|
|
7
|
+
*
|
|
8
|
+
* - `ApiKey` — server-to-server; the API key is sent as the password on the
|
|
9
|
+
* web/JSON-RPC routes, or as a bearer token on JSON-2.
|
|
10
|
+
* - `Password` — a login password (only valid for accounts without 2FA).
|
|
11
|
+
*/
|
|
12
|
+
export type OdooCredentials =
|
|
13
|
+
| {
|
|
14
|
+
readonly _tag: "ApiKey";
|
|
15
|
+
readonly username: string;
|
|
16
|
+
readonly apiKey: Redacted.Redacted<string>;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
readonly _tag: "Password";
|
|
20
|
+
readonly username: string;
|
|
21
|
+
readonly password: Redacted.Redacted<string>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Fully-resolved client configuration. No field has a default: zero assumptions. */
|
|
25
|
+
export interface OdooConfig {
|
|
26
|
+
readonly url: URL;
|
|
27
|
+
readonly db: string;
|
|
28
|
+
readonly credentials: OdooCredentials;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A config validation failure carrying a caller-selected diagnostic value.
|
|
33
|
+
* Secret-bearing inputs must pass a fixed redacted placeholder. v4's
|
|
34
|
+
* `ConfigError` wraps a `SchemaError` (data found but invalid) — the analogue
|
|
35
|
+
* of v3's `ConfigError.InvalidData`.
|
|
36
|
+
*/
|
|
37
|
+
const invalidData = (value: unknown, message: string): Config.ConfigError =>
|
|
38
|
+
new Config.ConfigError(
|
|
39
|
+
new Schema.SchemaError(new SchemaIssue.InvalidValue(Option.some(value), { message })),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse and validate the `ODOO_URL` value: must be a well-formed absolute URL
|
|
44
|
+
* without userinfo, query parameters, or a fragment, and must be `https`
|
|
45
|
+
* unless it targets localhost / a loopback address. TLS is never silently
|
|
46
|
+
* disabled.
|
|
47
|
+
*/
|
|
48
|
+
const parseUrl = (raw: string): Effect.Effect<URL, Config.ConfigError> => {
|
|
49
|
+
let url: URL;
|
|
50
|
+
try {
|
|
51
|
+
url = new URL(raw);
|
|
52
|
+
} catch {
|
|
53
|
+
return Effect.fail(invalidData("<redacted>", "ODOO_URL is not a valid absolute URL"));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") {
|
|
57
|
+
return Effect.fail(
|
|
58
|
+
invalidData(
|
|
59
|
+
"<redacted>",
|
|
60
|
+
"ODOO_URL must not contain userinfo, query parameters, or a fragment",
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isLoopback =
|
|
66
|
+
url.hostname === "localhost" ||
|
|
67
|
+
url.hostname === "127.0.0.1" ||
|
|
68
|
+
url.hostname === "[::1]" ||
|
|
69
|
+
url.hostname === "::1";
|
|
70
|
+
|
|
71
|
+
if (url.protocol !== "https:" && !isLoopback) {
|
|
72
|
+
return Effect.fail(
|
|
73
|
+
invalidData("<redacted>", "ODOO_URL must use https unless it targets a loopback host"),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return Effect.succeed(url);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const urlConfig: Config.Config<URL> = Config.string("URL").pipe(Config.mapOrFail(parseUrl));
|
|
81
|
+
|
|
82
|
+
const apiKeyCredentials: Config.Config<OdooCredentials> = Config.all({
|
|
83
|
+
username: Config.string("USERNAME"),
|
|
84
|
+
apiKey: Config.redacted("API_KEY"),
|
|
85
|
+
}).pipe(Config.map((c) => ({ _tag: "ApiKey" as const, ...c })));
|
|
86
|
+
|
|
87
|
+
const passwordCredentials: Config.Config<OdooCredentials> = Config.all({
|
|
88
|
+
username: Config.string("USERNAME"),
|
|
89
|
+
password: Config.redacted("PASSWORD"),
|
|
90
|
+
}).pipe(Config.map((c) => ({ _tag: "Password" as const, ...c })));
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Prefer an API key when `ODOO_API_KEY` is present; otherwise fall back to a
|
|
94
|
+
* login password (`ODOO_PASSWORD`). If neither secret is set, config resolution
|
|
95
|
+
* fails loudly — there is no anonymous default.
|
|
96
|
+
*/
|
|
97
|
+
const credentialsConfig: Config.Config<OdooCredentials> = apiKeyCredentials.pipe(
|
|
98
|
+
Config.orElse(() => passwordCredentials),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The client configuration, read from environment (or any `ConfigProvider`)
|
|
103
|
+
* under the `ODOO_` prefix: `ODOO_URL`, `ODOO_DB`, `ODOO_USERNAME`, and one of
|
|
104
|
+
* `ODOO_API_KEY` / `ODOO_PASSWORD`.
|
|
105
|
+
*/
|
|
106
|
+
export const OdooConfig: Config.Config<OdooConfig> = Config.all({
|
|
107
|
+
url: urlConfig,
|
|
108
|
+
db: Config.string("DB"),
|
|
109
|
+
credentials: credentialsConfig,
|
|
110
|
+
}).pipe(Config.nested("ODOO"));
|
package/src/domain.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Odoo search domains, in their native Polish-prefix form. A domain is a flat
|
|
3
|
+
* list mixing leaf conditions and the operators `&` (and), `|` (or), `!` (not).
|
|
4
|
+
* Adjacent leaves with no operator between them are implicitly ANDed by Odoo.
|
|
5
|
+
*
|
|
6
|
+
* We keep the leaf pragmatic — `[field, operator, value]` — rather than trying
|
|
7
|
+
* to type Odoo's full operator/value matrix, which drifts across versions.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Domain boolean operators, in Odoo's prefix notation. */
|
|
11
|
+
export type DomainOperator = "&" | "|" | "!";
|
|
12
|
+
|
|
13
|
+
/** A single condition: `[field, operator, value]`. */
|
|
14
|
+
export type DomainLeaf = readonly [string, string, unknown];
|
|
15
|
+
|
|
16
|
+
/** One token of a domain: either an operator or a leaf. */
|
|
17
|
+
export type DomainItem = DomainOperator | DomainLeaf;
|
|
18
|
+
|
|
19
|
+
/** A complete Odoo domain. */
|
|
20
|
+
export type Domain = ReadonlyArray<DomainItem>;
|
|
21
|
+
|
|
22
|
+
const isLeaf = (item: DomainItem): item is DomainLeaf => Array.isArray(item);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Rewrite a domain into a single, fully-explicit prefix expression: every
|
|
26
|
+
* implicit AND between adjacent leaves is made explicit with a leading `&`,
|
|
27
|
+
* so the result is one well-formed expression that can be safely nested inside
|
|
28
|
+
* a larger one. Port of Odoo's `expression.normalize_domain`.
|
|
29
|
+
*/
|
|
30
|
+
export const normalizeDomain = (domain: Domain): Domain => {
|
|
31
|
+
if (domain.length === 0) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const result: Array<DomainItem> = [];
|
|
36
|
+
// Number of expressions still expected to complete the current subtree.
|
|
37
|
+
let expected = 1;
|
|
38
|
+
|
|
39
|
+
for (const token of domain) {
|
|
40
|
+
if (expected === 0) {
|
|
41
|
+
// A previous expression already completed; an extra one means an
|
|
42
|
+
// implicit AND — make it explicit at the front.
|
|
43
|
+
result.unshift("&");
|
|
44
|
+
expected = 1;
|
|
45
|
+
}
|
|
46
|
+
result.push(token);
|
|
47
|
+
if (isLeaf(token)) {
|
|
48
|
+
expected -= 1;
|
|
49
|
+
} else if (token === "&" || token === "|") {
|
|
50
|
+
expected += 1;
|
|
51
|
+
}
|
|
52
|
+
// "!" is unary: it consumes and produces exactly one expression, so the
|
|
53
|
+
// expected count is unchanged.
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Fold several sub-domains together under one binary operator, normalizing
|
|
61
|
+
* each first. Empty sub-domains are dropped (they constrain nothing). Zero
|
|
62
|
+
* survivors yields the empty domain; one survivor is returned as-is.
|
|
63
|
+
*/
|
|
64
|
+
const combine = (op: "&" | "|", domains: ReadonlyArray<Domain>): Domain => {
|
|
65
|
+
const parts = domains.map(normalizeDomain).filter((d) => d.length > 0);
|
|
66
|
+
|
|
67
|
+
if (parts.length === 0) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
if (parts.length === 1) {
|
|
71
|
+
return parts[0]!;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const prefix: Array<DomainItem> = Array.from({ length: parts.length - 1 }, () => op);
|
|
75
|
+
return [...prefix, ...parts.flat()];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Conjoin sub-domains: every one must match. */
|
|
79
|
+
export const AND = (...domains: ReadonlyArray<Domain>): Domain => combine("&", domains);
|
|
80
|
+
|
|
81
|
+
/** Disjoin sub-domains: at least one must match. */
|
|
82
|
+
export const OR = (...domains: ReadonlyArray<Domain>): Domain => combine("|", domains);
|
|
83
|
+
|
|
84
|
+
/** Negate a domain. */
|
|
85
|
+
export const NOT = (domain: Domain): Domain => {
|
|
86
|
+
const normalized = normalizeDomain(domain);
|
|
87
|
+
if (normalized.length === 0) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
return ["!", ...normalized];
|
|
91
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Data } from "effect";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Why authentication failed. Distinct reasons so callers can branch without
|
|
5
|
+
* parsing messages:
|
|
6
|
+
*
|
|
7
|
+
* - `invalid-credentials` — wrong username/password or db.
|
|
8
|
+
* - `mfa-pending` — credentials accepted but a second factor is required
|
|
9
|
+
* before a usable session exists.
|
|
10
|
+
* - `api-key-expired-or-invalid` — the API key was rejected.
|
|
11
|
+
* - `totp-rpc-password` — hint: this account has 2FA enabled, so its login
|
|
12
|
+
* password cannot be used for RPC; an API key (used as the password) is
|
|
13
|
+
* required instead.
|
|
14
|
+
*/
|
|
15
|
+
export type OdooAuthReason =
|
|
16
|
+
| "invalid-credentials"
|
|
17
|
+
| "mfa-pending"
|
|
18
|
+
| "api-key-expired-or-invalid"
|
|
19
|
+
| "totp-rpc-password";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Authentication did not yield a usable session. Carries a structured
|
|
23
|
+
* {@link OdooAuthReason} rather than a free-text message.
|
|
24
|
+
*/
|
|
25
|
+
export class OdooAuthenticationError extends Data.TaggedError("OdooAuthenticationError")<{
|
|
26
|
+
readonly reason: OdooAuthReason;
|
|
27
|
+
readonly message: string;
|
|
28
|
+
}> {}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The complete error taxonomy for @hodeinavarro/odoo-rpc-ts. Every failure
|
|
3
|
+
* the client can produce is one of these tagged errors — nothing throws, and
|
|
4
|
+
* there is no blanket error type. See AGENTS.md § Error taxonomy.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { OdooTransportError, type RequestInfo, type TransportErrorKind } from "./transport.ts";
|
|
8
|
+
export { OdooAuthenticationError, type OdooAuthReason } from "./auth.ts";
|
|
9
|
+
export { SessionExpiredError } from "./session.ts";
|
|
10
|
+
export { SchemaDriftError } from "./schema.ts";
|
|
11
|
+
export { ProtocolUnsupportedError, type OdooProtocol } from "./protocol.ts";
|
|
12
|
+
export {
|
|
13
|
+
OdooServerError,
|
|
14
|
+
OdooAccessError,
|
|
15
|
+
OdooValidationError,
|
|
16
|
+
OdooMissingError,
|
|
17
|
+
OdooUserError,
|
|
18
|
+
OdooLockError,
|
|
19
|
+
serverFaultFields,
|
|
20
|
+
type OdooServerFault,
|
|
21
|
+
} from "./server.ts";
|
|
22
|
+
export { mapServerFault, type RawServerFault, type FaultCallSite } from "./mapFault.ts";
|
|
23
|
+
|
|
24
|
+
import type { OdooTransportError } from "./transport.ts";
|
|
25
|
+
import type { OdooAuthenticationError } from "./auth.ts";
|
|
26
|
+
import type { SessionExpiredError } from "./session.ts";
|
|
27
|
+
import type { SchemaDriftError } from "./schema.ts";
|
|
28
|
+
import type { ProtocolUnsupportedError } from "./protocol.ts";
|
|
29
|
+
import type { OdooServerFault } from "./server.ts";
|
|
30
|
+
|
|
31
|
+
/** Transport / wire-layer failures. */
|
|
32
|
+
export type OdooTransportFailure = OdooTransportError;
|
|
33
|
+
|
|
34
|
+
/** Everything about establishing or keeping a usable session. */
|
|
35
|
+
export type OdooAuthFailure = OdooAuthenticationError | SessionExpiredError;
|
|
36
|
+
|
|
37
|
+
/** Boundary-decoding failures. */
|
|
38
|
+
export type OdooDecodeFailure = SchemaDriftError;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The complete union of every error this client can fail with. Callers narrow
|
|
42
|
+
* with `Effect.catchTag` / `catchTags`.
|
|
43
|
+
*/
|
|
44
|
+
export type OdooError =
|
|
45
|
+
| OdooTransportError
|
|
46
|
+
| OdooAuthenticationError
|
|
47
|
+
| SessionExpiredError
|
|
48
|
+
| SchemaDriftError
|
|
49
|
+
| ProtocolUnsupportedError
|
|
50
|
+
| OdooServerFault;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { OdooAuthenticationError } from "./auth.ts";
|
|
2
|
+
import {
|
|
3
|
+
OdooAccessError,
|
|
4
|
+
OdooLockError,
|
|
5
|
+
OdooMissingError,
|
|
6
|
+
OdooServerError,
|
|
7
|
+
OdooUserError,
|
|
8
|
+
OdooValidationError,
|
|
9
|
+
type OdooServerFault,
|
|
10
|
+
} from "./server.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The raw fault as surfaced by a transport choke point, before mapping. Only
|
|
14
|
+
* `name` and `message` are guaranteed by Odoo; the rest may be absent and are
|
|
15
|
+
* normalized to empty by the transport before reaching here.
|
|
16
|
+
*/
|
|
17
|
+
export interface RawServerFault {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly arguments: ReadonlyArray<unknown>;
|
|
21
|
+
readonly context: Record<string, unknown>;
|
|
22
|
+
readonly debug?: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The call site, threaded through so faults carry where they came from. */
|
|
26
|
+
export interface FaultCallSite {
|
|
27
|
+
readonly model?: string | undefined;
|
|
28
|
+
readonly method?: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Concrete constructors for the mapped subtypes, keyed by dotted Python name.
|
|
33
|
+
* Any name absent from this table falls through to {@link OdooServerError},
|
|
34
|
+
* which preserves the raw `name` verbatim.
|
|
35
|
+
*/
|
|
36
|
+
const byName: Record<string, new (fields: ConstructorFields) => OdooServerFault> = {
|
|
37
|
+
"odoo.exceptions.AccessError": OdooAccessError,
|
|
38
|
+
"odoo.exceptions.ValidationError": OdooValidationError,
|
|
39
|
+
"odoo.exceptions.MissingError": OdooMissingError,
|
|
40
|
+
"odoo.exceptions.UserError": OdooUserError,
|
|
41
|
+
"odoo.exceptions.RedirectWarning": OdooUserError,
|
|
42
|
+
"odoo.exceptions.Warning": OdooUserError,
|
|
43
|
+
"odoo.exceptions.LockError": OdooLockError,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
interface ConstructorFields {
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly message: string;
|
|
49
|
+
readonly arguments: ReadonlyArray<unknown>;
|
|
50
|
+
readonly context: Record<string, unknown>;
|
|
51
|
+
readonly debug?: string;
|
|
52
|
+
readonly model?: string;
|
|
53
|
+
readonly method?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Map a raw Odoo fault to exactly one tagged subtype. Shared by all three
|
|
58
|
+
* transports' choke points so mapping lives in one place. Unknown names fall
|
|
59
|
+
* back to {@link OdooServerError} with `name` preserved.
|
|
60
|
+
*
|
|
61
|
+
* GOTCHA: `odoo.exceptions.AccessDenied` is *authentication* (bad password or
|
|
62
|
+
* API key — XML-RPC fault 3, distinct from AccessError's fault 4), so it maps
|
|
63
|
+
* to {@link OdooAuthenticationError}, not to a server fault. Accounts with
|
|
64
|
+
* TOTP enabled reject passwords over RPC entirely, hence the reason hint.
|
|
65
|
+
*/
|
|
66
|
+
export const mapServerFault = (
|
|
67
|
+
fault: RawServerFault,
|
|
68
|
+
site: FaultCallSite = {},
|
|
69
|
+
): OdooServerFault | OdooAuthenticationError => {
|
|
70
|
+
if (fault.name === "odoo.exceptions.AccessDenied") {
|
|
71
|
+
return new OdooAuthenticationError({
|
|
72
|
+
reason: "invalid-credentials",
|
|
73
|
+
message: fault.message,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const Ctor = byName[fault.name] ?? OdooServerError;
|
|
78
|
+
|
|
79
|
+
// Build with exactOptionalPropertyTypes: only attach optional keys when
|
|
80
|
+
// actually present, never as explicit `undefined`.
|
|
81
|
+
const fields: ConstructorFields = {
|
|
82
|
+
name: fault.name,
|
|
83
|
+
message: fault.message,
|
|
84
|
+
arguments: fault.arguments,
|
|
85
|
+
context: fault.context,
|
|
86
|
+
...(fault.debug !== undefined ? { debug: fault.debug } : {}),
|
|
87
|
+
...(site.model !== undefined ? { model: site.model } : {}),
|
|
88
|
+
...(site.method !== undefined ? { method: site.method } : {}),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return new Ctor(fields);
|
|
92
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Data } from "effect";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The wire protocols this client can speak. `json-2` is the Odoo 19+
|
|
5
|
+
* `/json/2/<model>/<method>` bearer API; `json-rpc` is `/jsonrpc` →
|
|
6
|
+
* `execute_kw`; `web` is the cookie-session `/web/dataset/call_kw` route.
|
|
7
|
+
*/
|
|
8
|
+
export type OdooProtocol = "json-rpc" | "json-2" | "web";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The requested protocol is not available on the resolved server version —
|
|
12
|
+
* e.g. asking a 16.0 server for JSON-2. Raised by the version resolver seam
|
|
13
|
+
* so callers fail fast with a typed reason instead of interpreting a raw 404.
|
|
14
|
+
*/
|
|
15
|
+
export class ProtocolUnsupportedError extends Data.TaggedError("ProtocolUnsupportedError")<{
|
|
16
|
+
readonly protocol: OdooProtocol;
|
|
17
|
+
readonly serverVersion: string;
|
|
18
|
+
readonly message: string;
|
|
19
|
+
}> {}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Data } from "effect";
|
|
2
|
+
import type { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A response reached us intact at the transport layer but did not match the
|
|
6
|
+
* schema we decode it through — the server's wire shape drifted (a version
|
|
7
|
+
* quirk, an unexpected null, a renamed field). We fail loudly with the raw
|
|
8
|
+
* payload attached rather than casting past the mismatch.
|
|
9
|
+
*
|
|
10
|
+
* `payload` is the undecoded value exactly as received, for diagnosis.
|
|
11
|
+
* `cause` is the `effect/Schema` `SchemaError` describing the mismatch (v4;
|
|
12
|
+
* formerly v3's `ParseResult.ParseError`).
|
|
13
|
+
*/
|
|
14
|
+
export class SchemaDriftError extends Data.TaggedError("SchemaDriftError")<{
|
|
15
|
+
readonly context: string;
|
|
16
|
+
readonly payload: unknown;
|
|
17
|
+
readonly cause: Schema.SchemaError;
|
|
18
|
+
}> {}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fields shared by every Odoo server fault. These are wire-decoded (the fault
|
|
5
|
+
* payload is normalized at each transport's choke point), so the classes use
|
|
6
|
+
* `Schema.TaggedErrorClass` rather than `Data.TaggedError`.
|
|
7
|
+
*
|
|
8
|
+
* - `name` — the raw Python exception name (e.g. `odoo.exceptions.AccessError`),
|
|
9
|
+
* preserved verbatim so nothing is lost even on the fallback path.
|
|
10
|
+
* - `message` — the human-facing message Odoo produced.
|
|
11
|
+
* - `arguments` — the exception's positional args, undecoded.
|
|
12
|
+
* - `context` — the Odoo `context` dict in effect at the call, if surfaced.
|
|
13
|
+
* - `debug` — the server traceback, when the server includes one.
|
|
14
|
+
* - `model` / `method` — the call site, threaded in by the choke point.
|
|
15
|
+
*/
|
|
16
|
+
export const serverFaultFields = {
|
|
17
|
+
name: Schema.String,
|
|
18
|
+
message: Schema.String,
|
|
19
|
+
arguments: Schema.Array(Schema.Unknown),
|
|
20
|
+
context: Schema.Record(Schema.String, Schema.Unknown),
|
|
21
|
+
debug: Schema.optional(Schema.String),
|
|
22
|
+
model: Schema.optional(Schema.String),
|
|
23
|
+
method: Schema.optional(Schema.String),
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Any Odoo application-level fault whose Python name we do not map to a more
|
|
28
|
+
* specific subtype. The raw `name` is preserved so callers can still branch on
|
|
29
|
+
* it, and no information is discarded.
|
|
30
|
+
*/
|
|
31
|
+
export class OdooServerError extends Schema.TaggedErrorClass<OdooServerError>()(
|
|
32
|
+
"OdooServerError",
|
|
33
|
+
serverFaultFields,
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
/** `odoo.exceptions.AccessError` / `AccessDenied` — permission denied. */
|
|
37
|
+
export class OdooAccessError extends Schema.TaggedErrorClass<OdooAccessError>()(
|
|
38
|
+
"OdooAccessError",
|
|
39
|
+
serverFaultFields,
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
/** `odoo.exceptions.ValidationError` — a constraint/validation was violated. */
|
|
43
|
+
export class OdooValidationError extends Schema.TaggedErrorClass<OdooValidationError>()(
|
|
44
|
+
"OdooValidationError",
|
|
45
|
+
serverFaultFields,
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
/** `odoo.exceptions.MissingError` — a referenced record no longer exists. */
|
|
49
|
+
export class OdooMissingError extends Schema.TaggedErrorClass<OdooMissingError>()(
|
|
50
|
+
"OdooMissingError",
|
|
51
|
+
serverFaultFields,
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
/** `odoo.exceptions.UserError` — a deliberate, user-facing business error. */
|
|
55
|
+
export class OdooUserError extends Schema.TaggedErrorClass<OdooUserError>()(
|
|
56
|
+
"OdooUserError",
|
|
57
|
+
serverFaultFields,
|
|
58
|
+
) {}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `odoo.exceptions.LockError` — concurrent-update / serialization lock failure
|
|
62
|
+
* (surfaced as a distinct exception on Odoo 19+).
|
|
63
|
+
*/
|
|
64
|
+
export class OdooLockError extends Schema.TaggedErrorClass<OdooLockError>()(
|
|
65
|
+
"OdooLockError",
|
|
66
|
+
serverFaultFields,
|
|
67
|
+
) {}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The union of every server-fault subtype. `mapServerFault` narrows a raw
|
|
71
|
+
* fault to exactly one of these.
|
|
72
|
+
*/
|
|
73
|
+
export type OdooServerFault =
|
|
74
|
+
| OdooServerError
|
|
75
|
+
| OdooAccessError
|
|
76
|
+
| OdooValidationError
|
|
77
|
+
| OdooMissingError
|
|
78
|
+
| OdooUserError
|
|
79
|
+
| OdooLockError;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Data } from "effect";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The cookie-based web session is no longer valid (JSON-RPC error code 100 /
|
|
5
|
+
* `odoo.http.SessionExpiredException`). The `session_id` must be re-minted via
|
|
6
|
+
* `/web/session/authenticate` before retrying. Distinct from
|
|
7
|
+
* {@link OdooAuthenticationError} because the credentials themselves may still
|
|
8
|
+
* be good — only the session died.
|
|
9
|
+
*/
|
|
10
|
+
export class SessionExpiredError extends Data.TaggedError("SessionExpiredError")<{
|
|
11
|
+
readonly message: string;
|
|
12
|
+
}> {}
|