npm.io
0.3.0 • Published 2d ago

@hodeinavarro/odoo-rpc-ts

Licence
MIT
Version
0.3.0
Deps
0
Size
584 kB
Vulns
0
Weekly
0

@hodeinavarro/odoo-rpc-ts

Effect-native, strongly-typed Odoo RPC client for TypeScript. Odoo 16–19, three wire protocols behind one seam — JSON-RPC, the new JSON-2 API (19+), and the cookie-session web route — with a tagged-error taxonomy you can catchTag instead of parsing messages.

Status: public and pre-1.0. Releases are published to the public npm registry with provenance.

Scope

This package owns generic Odoo transport, authentication/session, schema decoding, typed records, connection profiles, and a small set of reusable services. It does not own application-specific models, UI, persistence, workflow policy, or automatic protocol selection. XML-RPC and generated per-model clients are deliberately out of scope.

Install it from the public npm registry with:

pnpm add @hodeinavarro/odoo-rpc-ts effect@4.0.0-beta.93
pnpm add @effect/platform-node@4.0.0-beta.93   # or run in the browser with FetchHttpClient

Effect 4 is in beta and moves between betas — pin it exact (no caret) and upgrade deliberately.

Quick start

Set ODOO_URL, ODOO_DB, ODOO_USERNAME, and ODOO_API_KEY (or ODOO_PASSWORD), then:

import { Effect, Layer, Schema } from "effect";
import { NodeHttpClient } from "@effect/platform-node";
import { JsonRpcTransport, OdooClient, OdooClientLive, RpcLive } from "@hodeinavarro/odoo-rpc-ts";

// One layer graph: client → rpc → transport → your HTTP runtime.
const OdooLive = OdooClientLive.layer.pipe(
  Layer.provideMerge(RpcLive.layer),
  Layer.provide(JsonRpcTransport.layerConfig),
  Layer.provide(NodeHttpClient.layerUndici),
);

// Rows decode through a schema — drift fails loudly, never a silent cast.
const Partner = Schema.Struct({
  id: Schema.Number,
  name: Schema.String,
  email: Schema.Union([Schema.String, Schema.Literal(false)]), // Odoo sends false, not null
});

const companies = Effect.gen(function* () {
  const odoo = yield* OdooClient;
  return yield* odoo.searchRead(
    "res.partner",
    { domain: [["is_company", "=", true]], fields: ["id", "name", "email"], limit: 10 },
    Partner,
  );
});

await companies.pipe(Effect.provide(OdooLive), Effect.runPromise);

OdooClient gives you searchRead, search, read, create, write, unlink, fieldsGet, searchCount, readGroup, nameSearch, ref (resolve an XML id to [model, id]), and a generic call escape hatch — every op works over every transport. Rpc.callKw sits underneath for anything else. RpcLive.layer seeds the user's server-side context (lang, tz — via res.users.context_get, lazily on the first call) as the base tier of every call, so translated fields come back in the user's language; use RpcLive.layerWith() to opt out of seeding.

Everyday workflows

Find or create:

const findOrCreatePartner = (email: string, name: string) =>
  Effect.gen(function* () {
    const odoo = yield* OdooClient;
    const existing = yield* odoo.search("res.partner", {
      domain: [["email", "=", email]],
      limit: 1,
    });
    if (existing.length > 0) return existing[0]!;
    const [id] = yield* odoo.create("res.partner", { name, email });
    return id!;
  });

Bulk update with domain combinators (AND / OR / NOT):

import { AND, OR } from "@hodeinavarro/odoo-rpc-ts";

const archiveStaleLeads = Effect.gen(function* () {
  const odoo = yield* OdooClient;
  const stale = yield* odoo.search("crm.lead", {
    domain: AND(
      [["active", "=", true]],
      OR([["probability", "=", 0]], [["write_date", "<", "2025-01-01"]]),
    ),
  });
  if (stale.length === 0) return 0;
  yield* odoo.write("crm.lead", stale, { active: false });
  return stale.length;
});

Paginate a big model:

const allPartnerIds = Effect.gen(function* () {
  const odoo = yield* OdooClient;
  const out: number[] = [];
  for (let offset = 0; ; offset += 500) {
    const page = yield* odoo.search("res.partner", { limit: 500, offset });
    out.push(...page);
    if (page.length < 500) return out;
  }
});

Typed records and relations

Two per-protocol tiers — dialects of the same design, not competitors. Both make N+1 storms structurally impossible, and every RPC stays visible. The spec tier speaks the specification protocol Odoo grew in 17; the classic tier speaks the classic [id, name]-pair protocol every supported version (16+) understands. Pick by the oldest server you must reach.

Spec tier — declared prefetch (Odoo 17+ specification protocol) — declare the graph, get ONE web_search_read:

import { defineRecord, Many2One, OdooClient, OdooDateTime } from "@hodeinavarro/odoo-rpc-ts"
import { Schema } from "effect"

const Company = defineRecord("res.company", { name: Schema.String })
const Partner = defineRecord("res.partner", {
  name: Schema.String,
  create_date: OdooDateTime,            // parsed as UTC, never localized
  company_id: Many2One(Company),        // row.company_id?.name — already fetched
})

const rows = Effect.gen(function* () {
  const odoo = yield* OdooClient
  return yield* odoo.searchTyped(Partner, { domain: [["is_company", "=", true]], limit: 10 })
})

The version gate is explicit: the spec tier's relations REQUIRE Odoo 17+. Provide a VersionResolver layer and the gate holds automatically — a declared relation on Odoo 16 fails with ProtocolUnsupportedError before any round trip, while relation-free records degrade to plain search_read. saveTyped (17+) writes and returns the fresh nested snapshot in one call. For anything that must also run on 16, use the classic tier below.

Classic tier — explicit traversal (all versions, 16+) — rows keep the classic [id, name] refs; batch the hop when you need it, exactly one deduped read:

import { Many2OneRefOrNull, OdooClient } from "@hodeinavarro/odoo-rpc-ts"

const PartnerRow = Schema.Struct({
  id: Schema.Number,
  name: Schema.String,
  company_id: Many2OneRefOrNull,        // Odoo's [id, name] pair; false -> null
})
const CompanyRow = Schema.Struct({ id: Schema.Number, name: Schema.String })

const pairs = Effect.gen(function* () {
  const odoo = yield* OdooClient
  const rows = yield* odoo.searchRecordsTyped("res.partner", {}, PartnerRow)
  return yield* rows.joinRelated("company_id", "res.company", CompanyRow) // ONE read
})

x2many writes without magic tuples

import { Command } from "@hodeinavarro/odoo-rpc-ts"

yield* odoo.write("res.partner", [companyId], {
  child_ids: [Command.create({ name: "New contact" }), Command.link(existingId)],
})

Services: databases, reports, profiles

import { DbService, ReportService } from "@hodeinavarro/odoo-rpc-ts"

// database administration (master-password gated; Node-oriented)
const names = yield* DbService.listDatabases(url)
yield* DbService.duplicate(url, master, "prod", "staging")

// report downloads over the cookie session — works on ALL of 16-19
const pdf = yield* ReportService.download(session, {
  reportName: "base.report_irmodeloverview",
  ids: [modelId],
})

Connection profiles never serialize secrets: ProfileData holds the metadata, secrets cross only as Redacted through a SecretStore you implement (OS keyring in Node, IndexedDB in the browser); in-memory layers ship for tests. Besides api-key/password presets, saveSessionProfile(name, url, sessionId) stores a harvested session_id cookie (the TOTP/SSO flow — no credentials exist); loadProfile returns a tagged union, and its "session" arm feeds CookieSessionLive.fromExisting directly instead of fabricating a config.

Bring your own HTTP layer

The library depends only on the abstract HttpClient tag — timeouts, proxies, custom CAs, and retry policy are yours:

import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { Layer } from "effect"

const TunedHttp = Layer.effect(
  HttpClient.HttpClient,
  Effect.map(HttpClient.HttpClient, (client) => client.pipe(HttpClient.retryTransient({ times: 3 }))),
).pipe(Layer.provide(FetchHttpClient.layer))

Errors are a typed API

Every failure is a tagged error carrying the raw fault, the Python exception name, and the call site — normalized identically across all three protocols:

const created = Effect.gen(function* () {
  const odoo = yield* OdooClient;
  return yield* odoo.create("res.partner", { name: "Ada" });
}).pipe(
  Effect.catchTags({
    OdooValidationError: (e) => Effect.die(`rejected by a constraint: ${e.message}`),
    OdooAccessError: () => Effect.succeed([]), // degrade gracefully
    OdooAuthenticationError: (e) => Effect.die(e.reason), // "invalid-credentials" | "mfa-pending" | …
  }),
);

The full union: OdooTransportError · OdooAuthenticationError · SessionExpiredError · SchemaDriftError · ProtocolUnsupportedError · OdooServerError (+ OdooAccessError, OdooValidationError, OdooMissingError, OdooUserError, OdooLockError). Unknown server faults are preserved on OdooServerError with the raw name — never swallowed. OdooTransportError retains only a sanitized { method, url } request, failure kind, safe message, and optional HTTP status; it never exposes the platform request, response, headers, body, or cause.

Pick your transport

Same application code, different layer — always your explicit choice:

// Server-to-server, stateless: /jsonrpc + execute_kw (Odoo 16–19)
Layer.provide(JsonRpcTransport.layerConfig);

// Odoo 19+: /json/2, bearer API key, real HTTP status codes
Layer.provide(Json2Transport.layerConfig);

// Browser/long-lived: cookie session over /web/dataset/call_kw
Layer.provide(WebTransport.layerConfig).pipe(Layer.provideMerge(CookieSessionLive.layerConfig));

Cookie sessions expire; recovery is opt-in, never magic:

import { CookieSession, retryOnSessionExpired } from "@hodeinavarro/odoo-rpc-ts";

const resilientCount = Effect.gen(function* () {
  const session = yield* CookieSession;
  const odoo = yield* OdooClient;
  return yield* retryOnSessionExpired(odoo.searchCount("res.partner", []), session);
});

Already hold a session_id minted elsewhere — e.g. harvested from an embedded browser window after the user completed the real /web/login page (the only stock flow for TOTP/SSO accounts)? Adopt it directly; the client never holds credentials:

import { Redacted } from "effect";
import { CookieSessionLive, WebTransport } from "@hodeinavarro/odoo-rpc-ts";

const url = new URL("https://erp.example.com");

const HarvestedOdoo = OdooClientLive.layer.pipe(
  Layer.provideMerge(RpcLive.layer),
  Layer.provide(WebTransport.layer({ url })), // no credentials needed
  Layer.provide(
    CookieSessionLive.layerFromExisting({
      url,
      sessionId: Redacted.make(harvestedCookieValue),
      // Optional renew hook: mint a fresh cookie after an invalidate (e.g.
      // reopen the login window). Without it the session cannot recover —
      // once the server kills it, calls (and the one relogin attempted by
      // retryOnSessionExpired) fail with SessionExpiredError, and your shell
      // constructs a new session from a fresh cookie.
      // renew: driveLoginWindow,
    }),
  ),
  Layer.provide(NodeHttpClient.layerUndici),
);

The injected session hydrates honestly on first use via POST /web/session/get_session_info, so login still yields the real uid/context/version — and a dead cookie surfaces immediately as SessionExpiredError.

Notes: API keys work as the password on every route (and are required for accounts with 2FA). JSON-2 is bearer-only and keyword-only — the client handles the encoding differences for you via the transport's dialect.

Testing without a server

@hodeinavarro/odoo-rpc-ts/testing ships a deterministic FakeTransport with a call log:

import * as FakeTransport from "@hodeinavarro/odoo-rpc-ts/testing";

const fake = FakeTransport.make({
  "res.partner": {
    search_read: () => [{ id: 1, name: "Alice", email: "alice@example.com" }],
    create: (params) => (params.args[0] as unknown[]).map((_, i) => 100 + i),
  },
});

const TestLayer = OdooClientLive.layer.pipe(
  Layer.provideMerge(RpcLive.layer),
  Layer.provide(fake.layer),
);

Config is effect/Config, so tests can inject values without touching env:

import { ConfigProvider } from "effect";

const TestConfig = ConfigProvider.layer(
  ConfigProvider.fromEnv({
    env: {
      ODOO_URL: "https://mycompany.odoo.com",
      ODOO_DB: "mycompany",
      ODOO_USERNAME: "integration@mycompany.com",
      ODOO_API_KEY: "…",
    },
  }),
);

Want the real thing? pnpm harness up 16.0 (or 17/18/19) boots a disposable seeded Odoo in Docker and pnpm test:integration runs the live suite against it — see harness/README.md.

Support matrix

16.0 17.0 18.0 19.0
JSON-RPC /jsonrpc ✓ (deprecated upstream)
Web session + call_kw
JSON-2 /json/2 (bearer)
API key auth (as password)

XML-RPC is deliberately out of scope — JSON-RPC reaches the same services on every supported version with strictly richer errors.

Design, in one paragraph

Everything returns Effect<A, OdooError, R> — nothing throws. Services are Context.Services wired with Layers; the HTTP runtime is yours (effect — v4, incl. effect/unstable/http — is the only peer dependency, so it runs wherever Effect does, browser included). Responses are schema-decoded at the boundary (SchemaDriftError on drift), secrets stay Redacted, TLS is never disabled, and no URL/db/credential is ever assumed. The wire behavior was mapped from pinned Odoo 16–19 source snapshots and exercised against disposable instances. See the reproducible protocol verification record for the exact upstream revisions and source paths; architectural decisions live in AGENTS.md.

Every example in this README is compile-checked in CI (test/readme-snippets.test-d.ts).

Status

Pre-1.0. Core is implemented, the unit suite covers the shared API, and CI runs the integration suite against Odoo 16, 17, 18, and 19. The API may still move while Effect 4 remains in beta.

See CONTRIBUTING.md for the local gates and SECURITY.md for private vulnerability reporting.

Keywords