@skaile/forge-editor-directus 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ ### Added
6
+
7
+ - First release: `createDirectusAdapter({ url, token, authenticate?, authorize?, users?, collections?, client?, fetch? })`, the stored ports of `@skaile/forge-editor` (`identity`, `directory`, `documents` with content versions, `collabState`, `settings`; no `assets`) over three Directus collections (`editor_documents`, `editor_states`, `editor_settings`), and `restItemsClient({ url, token, fetch? })`, the `fetch` transport behind it (`DirectusItemsClient`: `readOne`, `readMany` with `limit=-1`, `updateOne`, `createOne`, `deleteOne`). No Directus SDK. A contract test runs the same scenarios against `createJsonAdapter` and the Directus adapter. Missing items are detected without relying on 404, because Directus 11 answers 403 for `GET /items/<c>/<id>` of a missing item and 204 (no body, nothing created) for `PATCH` of one: `readOne` is a `filter[<key>][_eq]` list query (`null` for an empty list), `updateOne` is `false` for 204, 404 or an answer without `data`. Both take the primary-key column (`id`; `user_id` for settings).
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # @skaile/forge-editor-directus
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The storage of [`@skaile/forge-editor`](../editor) in a [Directus](https://directus.io) instance: documents (TipTap JSON with a content version), the collaboration server's Yjs state and per-user editor settings, over the Directus REST items API. The transport is `fetch`; there is no dependency on `@directus/sdk`.
4
+
5
+ ```ts
6
+ import { createDirectusAdapter } from '@skaile/forge-editor-directus'
7
+
8
+ const ports = createDirectusAdapter({
9
+ url: 'http://localhost:8055',
10
+ token: process.env.DIRECTUS_TOKEN!,
11
+ authenticate: async ({ headers }) => userOfSession(headers), // null = not signed in
12
+ })
13
+ // provideEditorServer({ ports: { ...ports, models, index, ... }, blockTypes })
14
+ ```
15
+
16
+ `createDirectusAdapter` returns `Omit<StoredPorts, 'assets'>` (see `@skaile/forge-editor/adapters`): `identity`, `directory`, `documents`, `collabState`, `settings`. The identity hooks (`authenticate`, `authorize`, `users`) behave as in `createJsonAdapter`: without `authenticate` there is a development user outside `NODE_ENV=production` and nobody in production.
17
+
18
+ - `documents`: `storeContent` only updates (create with `putDocument`, a missing document is not an error); every write sets a new random `version` and `updated_at`, `contentVersion` returns it. `deleteDocument` removes content and collaboration state. There is no `listTree` / `createDocument` / `moveDocument`, so the document-tree routes answer 501.
19
+ - `assets`: none, the image routes answer 501.
20
+ - JSON is written as text and read both ways, so it works whether the collection's `json` fields are registered in Directus or not.
21
+
22
+ ## Collections
23
+
24
+ Three collections with these columns (SQLite, in the style of Directus' own `JSON` columns); the adapter does not create them:
25
+
26
+ ```sql
27
+ CREATE TABLE editor_documents (id TEXT PRIMARY KEY NOT NULL, content JSON NOT NULL, version TEXT NOT NULL, updated_at TEXT NOT NULL);
28
+ CREATE TABLE editor_states (id TEXT PRIMARY KEY NOT NULL, state TEXT NOT NULL); -- the state bytes as base64
29
+ CREATE TABLE editor_settings (user_id TEXT PRIMARY KEY NOT NULL, json JSON NOT NULL);
30
+ ```
31
+
32
+ Other names: `collections: { documents, states, settings }`. The token needs full access to the three collections; an admin token works, and the tables need no registration in Directus (untracked tables are reachable through `/items` for admins). Directus 11 answers `GET /items/<c>/<id>` for a missing item with 403, not 404, and `PATCH` of a missing item with 204 and no body, so the adapter never relies on a status to detect a missing item: it reads through `GET /items/<c>?filter[<key>][_eq]=<id>&limit=1` (empty list = missing) and reads a PATCH answer without `data` as "not updated" before it creates the row. A 403 on a list read stays an error (token without access).
33
+
34
+ ## Transport
35
+
36
+ `restItemsClient({ url, token, fetch? })` implements `DirectusItemsClient` (`readOne` is a filtered list query, empty → `null`; `readMany` with `limit=-1`; `updateOne` PATCH with `fields=<key>`, 204/404/no `data` → `false`; `createOne`; `deleteOne` 204/404 is fine; any other non-2xx throws). Ids are URL-encoded (`gutachten:<uuid>`). Pass `client` instead to use your own transport.
@@ -0,0 +1,36 @@
1
+ import { DirectusItemsClient } from "./client.js";
2
+ import { AdapterHooks, StoredPorts } from "@skaile/forge-editor/adapters";
3
+ //#region src/adapter.d.ts
4
+ export interface DirectusAdapterOptions extends AdapterHooks {
5
+ /** Base URL of the Directus instance. */
6
+ url: string;
7
+ /** Static token with access to the three collections (an admin token, as the app uses today). */
8
+ token: string;
9
+ /** Collection names; defaults `editor_documents`, `editor_states`, `editor_settings`. */
10
+ collections?: {
11
+ documents?: string;
12
+ states?: string;
13
+ settings?: string;
14
+ };
15
+ /** The transport (tests); default `restItemsClient({ url, token, fetch })`. */
16
+ client?: DirectusItemsClient;
17
+ fetch?: typeof fetch;
18
+ }
19
+ /** The stored ports of an editor host, without `assets` (the image routes answer 501). */
20
+ export type DirectusPorts = Omit<StoredPorts, 'assets'>;
21
+ /**
22
+ * The editor's adapter on three Directus collections over the REST items API:
23
+ *
24
+ * editor_documents(id PK, content json, version text, updated_at text) TipTap JSON + opaque content version
25
+ * editor_states(id PK, state text) the collab server's state bytes, base64
26
+ * editor_settings(user_id PK, json json) per-user editor settings
27
+ *
28
+ * Every write of a document's content (`storeContent`, `putDocument`) stores a fresh random `version`, which
29
+ * `contentVersion` returns. `storeContent` only updates existing documents: create them with `putDocument`. There is
30
+ * no `listTree` / `createDocument` / `moveDocument` (the document routes answer 501), no `assets` port and no theme
31
+ * library. Single-item requests only, no transaction: `deleteDocument` removes the content first, so a concurrent
32
+ * `storeContent` finds nothing to update.
33
+ */
34
+ export declare function createDirectusAdapter(options: DirectusAdapterOptions): DirectusPorts;
35
+ //#endregion
36
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1,113 @@
1
+ import { restItemsClient } from "./client.js";
2
+ import { directoryOf, identityOf, settingsKey } from "./hooks.js";
3
+ import { randomUUID } from "node:crypto";
4
+ //#region src/adapter.ts
5
+ /**
6
+ * Directus returns a `json` field parsed when the collection is registered with the `cast-json` special and as the stored
7
+ * text otherwise; the adapter writes JSON as text (valid either way) and reads both.
8
+ */
9
+ function jsonField(value) {
10
+ if (value === null || value === void 0) return null;
11
+ return typeof value === "string" ? JSON.parse(value) : value;
12
+ }
13
+ /**
14
+ * The editor's adapter on three Directus collections over the REST items API:
15
+ *
16
+ * editor_documents(id PK, content json, version text, updated_at text) TipTap JSON + opaque content version
17
+ * editor_states(id PK, state text) the collab server's state bytes, base64
18
+ * editor_settings(user_id PK, json json) per-user editor settings
19
+ *
20
+ * Every write of a document's content (`storeContent`, `putDocument`) stores a fresh random `version`, which
21
+ * `contentVersion` returns. `storeContent` only updates existing documents: create them with `putDocument`. There is
22
+ * no `listTree` / `createDocument` / `moveDocument` (the document routes answer 501), no `assets` port and no theme
23
+ * library. Single-item requests only, no transaction: `deleteDocument` removes the content first, so a concurrent
24
+ * `storeContent` finds nothing to update.
25
+ */
26
+ function createDirectusAdapter(options) {
27
+ const client = options.client ?? restItemsClient({
28
+ url: options.url,
29
+ token: options.token,
30
+ fetch: options.fetch
31
+ });
32
+ const documents = options.collections?.documents ?? "editor_documents";
33
+ const states = options.collections?.states ?? "editor_states";
34
+ const settings = options.collections?.settings ?? "editor_settings";
35
+ const sortedIds = (rows) => rows.map((row) => String(row.id)).sort();
36
+ return {
37
+ identity: identityOf(options),
38
+ directory: directoryOf(options),
39
+ documents: {
40
+ async loadContent(id) {
41
+ return jsonField((await client.readOne(documents, id, ["content"], "id"))?.content);
42
+ },
43
+ async storeContent(id, content) {
44
+ await client.updateOne(documents, id, {
45
+ content: JSON.stringify(content),
46
+ version: randomUUID(),
47
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
48
+ }, "id");
49
+ },
50
+ async contentVersion(id) {
51
+ const version = (await client.readOne(documents, id, ["version"], "id"))?.version;
52
+ return typeof version === "string" ? version : null;
53
+ },
54
+ async listContents() {
55
+ return (await client.readMany(documents, { fields: ["id", "content"] })).map((row) => ({
56
+ id: String(row.id),
57
+ content: jsonField(row.content)
58
+ })).filter((entry) => entry.content !== null).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
59
+ },
60
+ async putDocument(id, content) {
61
+ const fields = {
62
+ content: JSON.stringify(content),
63
+ version: randomUUID(),
64
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
65
+ };
66
+ if (!await client.updateOne(documents, id, fields, "id")) await client.createOne(documents, {
67
+ id,
68
+ ...fields
69
+ });
70
+ },
71
+ async deleteDocument(id) {
72
+ await client.deleteOne(documents, id);
73
+ await client.deleteOne(states, id);
74
+ },
75
+ async listDocumentIds() {
76
+ return sortedIds(await client.readMany(documents, { fields: ["id"] }));
77
+ }
78
+ },
79
+ collabState: {
80
+ async loadState(id) {
81
+ const state = (await client.readOne(states, id, ["state"], "id"))?.state;
82
+ return typeof state === "string" ? new Uint8Array(Buffer.from(state, "base64")) : null;
83
+ },
84
+ async storeState(id, state) {
85
+ const encoded = Buffer.from(state).toString("base64");
86
+ if (!await client.updateOne(states, id, { state: encoded }, "id")) await client.createOne(states, {
87
+ id,
88
+ state: encoded
89
+ });
90
+ },
91
+ async removeState(id) {
92
+ await client.deleteOne(states, id);
93
+ }
94
+ },
95
+ settings: {
96
+ async loadSettings(user) {
97
+ return jsonField((await client.readOne(settings, settingsKey(user), ["json"], "user_id"))?.json);
98
+ },
99
+ async storeSettings(user, value) {
100
+ const key = settingsKey(user);
101
+ const json = JSON.stringify(value);
102
+ if (!await client.updateOne(settings, key, { json }, "user_id")) await client.createOne(settings, {
103
+ user_id: key,
104
+ json
105
+ });
106
+ }
107
+ }
108
+ };
109
+ }
110
+ //#endregion
111
+ export { createDirectusAdapter };
112
+
113
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.js","names":[],"sources":["../src/adapter.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto'\nimport type { AdapterHooks, StoredPorts } from '@skaile/forge-editor/adapters'\nimport type { JSONContent } from '@tiptap/core'\nimport { type DirectusItemsClient, type DirectusRow, restItemsClient } from './client'\nimport { directoryOf, identityOf, settingsKey } from './hooks'\n\nexport interface DirectusAdapterOptions extends AdapterHooks {\n /** Base URL of the Directus instance. */\n url: string\n /** Static token with access to the three collections (an admin token, as the app uses today). */\n token: string\n /** Collection names; defaults `editor_documents`, `editor_states`, `editor_settings`. */\n collections?: { documents?: string, states?: string, settings?: string }\n /** The transport (tests); default `restItemsClient({ url, token, fetch })`. */\n client?: DirectusItemsClient\n fetch?: typeof fetch\n}\n\n/** The stored ports of an editor host, without `assets` (the image routes answer 501). */\nexport type DirectusPorts = Omit<StoredPorts, 'assets'>\n\n/**\n * Directus returns a `json` field parsed when the collection is registered with the `cast-json` special and as the stored\n * text otherwise; the adapter writes JSON as text (valid either way) and reads both.\n */\nfunction jsonField<T>(value: unknown): T | null {\n if (value === null || value === undefined) return null\n return (typeof value === 'string' ? JSON.parse(value) : value) as T\n}\n\n/**\n * The editor's adapter on three Directus collections over the REST items API:\n *\n * editor_documents(id PK, content json, version text, updated_at text) TipTap JSON + opaque content version\n * editor_states(id PK, state text) the collab server's state bytes, base64\n * editor_settings(user_id PK, json json) per-user editor settings\n *\n * Every write of a document's content (`storeContent`, `putDocument`) stores a fresh random `version`, which\n * `contentVersion` returns. `storeContent` only updates existing documents: create them with `putDocument`. There is\n * no `listTree` / `createDocument` / `moveDocument` (the document routes answer 501), no `assets` port and no theme\n * library. Single-item requests only, no transaction: `deleteDocument` removes the content first, so a concurrent\n * `storeContent` finds nothing to update.\n */\nexport function createDirectusAdapter(options: DirectusAdapterOptions): DirectusPorts {\n const client = options.client ?? restItemsClient({ url: options.url, token: options.token, fetch: options.fetch })\n const documents = options.collections?.documents ?? 'editor_documents'\n const states = options.collections?.states ?? 'editor_states'\n const settings = options.collections?.settings ?? 'editor_settings'\n\n const sortedIds = (rows: DirectusRow[]) => rows.map(row => String(row.id)).sort()\n\n return {\n identity: identityOf(options),\n directory: directoryOf(options),\n\n documents: {\n async loadContent(id) {\n return jsonField<JSONContent>((await client.readOne(documents, id, ['content'], 'id'))?.content)\n },\n async storeContent(id, content) {\n await client.updateOne(documents, id, { content: JSON.stringify(content), version: randomUUID(), updated_at: new Date().toISOString() }, 'id')\n },\n async contentVersion(id) {\n const version = (await client.readOne(documents, id, ['version'], 'id'))?.version\n return typeof version === 'string' ? version : null\n },\n async listContents() {\n const rows = await client.readMany(documents, { fields: ['id', 'content'] })\n return rows\n .map(row => ({ id: String(row.id), content: jsonField<JSONContent>(row.content) }))\n .filter((entry): entry is { id: string, content: JSONContent } => entry.content !== null)\n .sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)\n },\n\n async putDocument(id, content) {\n const fields = { content: JSON.stringify(content), version: randomUUID(), updated_at: new Date().toISOString() }\n if (!await client.updateOne(documents, id, fields, 'id')) await client.createOne(documents, { id, ...fields })\n },\n async deleteDocument(id) {\n await client.deleteOne(documents, id)\n await client.deleteOne(states, id)\n },\n async listDocumentIds() {\n return sortedIds(await client.readMany(documents, { fields: ['id'] }))\n },\n },\n\n collabState: {\n async loadState(id) {\n const state = (await client.readOne(states, id, ['state'], 'id'))?.state\n return typeof state === 'string' ? new Uint8Array(Buffer.from(state, 'base64')) : null\n },\n async storeState(id, state) {\n const encoded = Buffer.from(state).toString('base64')\n if (!await client.updateOne(states, id, { state: encoded }, 'id')) await client.createOne(states, { id, state: encoded })\n },\n async removeState(id) {\n await client.deleteOne(states, id)\n },\n },\n\n settings: {\n async loadSettings(user) {\n return jsonField((await client.readOne(settings, settingsKey(user), ['json'], 'user_id'))?.json)\n },\n async storeSettings(user, value) {\n const key = settingsKey(user)\n const json = JSON.stringify(value)\n if (!await client.updateOne(settings, key, { json }, 'user_id')) await client.createOne(settings, { user_id: key, json })\n },\n },\n }\n}\n"],"mappings":";;;;;;;;AAyBA,SAAS,UAAa,OAA0B;CAC9C,IAAI,UAAU,QAAQ,UAAU,KAAA,GAAW,OAAO;CAClD,OAAQ,OAAO,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;AAC1D;;;;;;;;;;;;;;AAeA,SAAgB,sBAAsB,SAAgD;CACpF,MAAM,SAAS,QAAQ,UAAU,gBAAgB;EAAE,KAAK,QAAQ;EAAK,OAAO,QAAQ;EAAO,OAAO,QAAQ;CAAM,CAAC;CACjH,MAAM,YAAY,QAAQ,aAAa,aAAa;CACpD,MAAM,SAAS,QAAQ,aAAa,UAAU;CAC9C,MAAM,WAAW,QAAQ,aAAa,YAAY;CAElD,MAAM,aAAa,SAAwB,KAAK,KAAI,QAAO,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK;CAEhF,OAAO;EACL,UAAU,WAAW,OAAO;EAC5B,WAAW,YAAY,OAAO;EAE9B,WAAW;GACT,MAAM,YAAY,IAAI;IACpB,OAAO,WAAwB,MAAM,OAAO,QAAQ,WAAW,IAAI,CAAC,SAAS,GAAG,IAAI,EAAA,EAAI,OAAO;GACjG;GACA,MAAM,aAAa,IAAI,SAAS;IAC9B,MAAM,OAAO,UAAU,WAAW,IAAI;KAAE,SAAS,KAAK,UAAU,OAAO;KAAG,SAAS,WAAW;KAAG,6BAAY,IAAI,KAAK,EAAA,CAAE,YAAY;IAAE,GAAG,IAAI;GAC/I;GACA,MAAM,eAAe,IAAI;IACvB,MAAM,WAAW,MAAM,OAAO,QAAQ,WAAW,IAAI,CAAC,SAAS,GAAG,IAAI,EAAA,EAAI;IAC1E,OAAO,OAAO,YAAY,WAAW,UAAU;GACjD;GACA,MAAM,eAAe;IAEnB,QAAO,MADY,OAAO,SAAS,WAAW,EAAE,QAAQ,CAAC,MAAM,SAAS,EAAE,CAAC,EAAA,CAExE,KAAI,SAAQ;KAAE,IAAI,OAAO,IAAI,EAAE;KAAG,SAAS,UAAuB,IAAI,OAAO;IAAE,EAAE,CAAC,CAClF,QAAQ,UAAyD,MAAM,YAAY,IAAI,CAAC,CACxF,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,KAAK,KAAK,EAAE,KAAK,EAAE,KAAK,IAAI,CAAC;GAC1D;GAEA,MAAM,YAAY,IAAI,SAAS;IAC7B,MAAM,SAAS;KAAE,SAAS,KAAK,UAAU,OAAO;KAAG,SAAS,WAAW;KAAG,6BAAY,IAAI,KAAK,EAAA,CAAE,YAAY;IAAE;IAC/G,IAAI,CAAC,MAAM,OAAO,UAAU,WAAW,IAAI,QAAQ,IAAI,GAAG,MAAM,OAAO,UAAU,WAAW;KAAE;KAAI,GAAG;IAAO,CAAC;GAC/G;GACA,MAAM,eAAe,IAAI;IACvB,MAAM,OAAO,UAAU,WAAW,EAAE;IACpC,MAAM,OAAO,UAAU,QAAQ,EAAE;GACnC;GACA,MAAM,kBAAkB;IACtB,OAAO,UAAU,MAAM,OAAO,SAAS,WAAW,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;GACvE;EACF;EAEA,aAAa;GACX,MAAM,UAAU,IAAI;IAClB,MAAM,SAAS,MAAM,OAAO,QAAQ,QAAQ,IAAI,CAAC,OAAO,GAAG,IAAI,EAAA,EAAI;IACnE,OAAO,OAAO,UAAU,WAAW,IAAI,WAAW,OAAO,KAAK,OAAO,QAAQ,CAAC,IAAI;GACpF;GACA,MAAM,WAAW,IAAI,OAAO;IAC1B,MAAM,UAAU,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,QAAQ;IACpD,IAAI,CAAC,MAAM,OAAO,UAAU,QAAQ,IAAI,EAAE,OAAO,QAAQ,GAAG,IAAI,GAAG,MAAM,OAAO,UAAU,QAAQ;KAAE;KAAI,OAAO;IAAQ,CAAC;GAC1H;GACA,MAAM,YAAY,IAAI;IACpB,MAAM,OAAO,UAAU,QAAQ,EAAE;GACnC;EACF;EAEA,UAAU;GACR,MAAM,aAAa,MAAM;IACvB,OAAO,WAAW,MAAM,OAAO,QAAQ,UAAU,YAAY,IAAI,GAAG,CAAC,MAAM,GAAG,SAAS,EAAA,EAAI,IAAI;GACjG;GACA,MAAM,cAAc,MAAM,OAAO;IAC/B,MAAM,MAAM,YAAY,IAAI;IAC5B,MAAM,OAAO,KAAK,UAAU,KAAK;IACjC,IAAI,CAAC,MAAM,OAAO,UAAU,UAAU,KAAK,EAAE,KAAK,GAAG,SAAS,GAAG,MAAM,OAAO,UAAU,UAAU;KAAE,SAAS;KAAK;IAAK,CAAC;GAC1H;EACF;CACF;AACF"}
@@ -0,0 +1,39 @@
1
+ //#region src/client.d.ts
2
+ export type DirectusRow = Record<string, unknown>;
3
+ /** The slice of the Directus items API the adapter needs; inject a fake in tests. */
4
+ export interface DirectusItemsClient {
5
+ /**
6
+ * One item by primary key, `null` when there is none. Read through a filtered list query (`filter[<key>][_eq]`), not
7
+ * `GET /items/<c>/<id>`: Directus 11 answers 403 (not 404) for a missing item, even to an admin token.
8
+ * `primaryKey` names the key column, default `id`.
9
+ */
10
+ readOne(collection: string, id: string, fields?: string[], primaryKey?: string): Promise<DirectusRow | null>;
11
+ /** Every item (`limit=-1`: Directus cuts an unlimited query to 100 otherwise). */
12
+ readMany(collection: string, query?: {
13
+ fields?: string[];
14
+ }): Promise<DirectusRow[]>;
15
+ /**
16
+ * Patches one item; `false` = there is no such item. Directus 11 answers PATCH of a missing id with 204 and no
17
+ * body (nothing is created), PATCH of an existing one with 200 and the item; 404 is read as missing as well.
18
+ * `primaryKey` names the key column, default `id`.
19
+ */
20
+ updateOne(collection: string, id: string, patch: DirectusRow, primaryKey?: string): Promise<boolean>;
21
+ createOne(collection: string, row: DirectusRow): Promise<void>;
22
+ /** Deletes one item; a missing item (204 or 404) is fine. */
23
+ deleteOne(collection: string, id: string): Promise<void>;
24
+ }
25
+ export interface RestItemsClientOptions {
26
+ /** Base URL of the Directus instance, e.g. `http://localhost:8055`. */
27
+ url: string;
28
+ /** Static token (`Authorization: Bearer`). Directus answers a missing item with 403, not 404, and a 403 is an error here: reads go through a filtered list query, so use an admin token or full access to the collections. */
29
+ token: string;
30
+ fetch?: typeof fetch;
31
+ }
32
+ /**
33
+ * The Directus items client over `fetch` (no `@directus/sdk`): `PATCH|DELETE {url}/items/{collection}/{id}`,
34
+ * `GET|POST {url}/items/{collection}` (`readOne` is a `filter[<key>][_eq]` list query with `limit=1`). Ids are
35
+ * URL-encoded (`gutachten:<uuid>` holds a colon). Any answer other than 2xx throws, except where a member says otherwise.
36
+ */
37
+ export declare function restItemsClient({ url, token, fetch: fetchImpl }: RestItemsClientOptions): DirectusItemsClient;
38
+ //#endregion
39
+ //# sourceMappingURL=client.d.ts.map
package/dist/client.js ADDED
@@ -0,0 +1,73 @@
1
+ //#region src/client.ts
2
+ /**
3
+ * The Directus items client over `fetch` (no `@directus/sdk`): `PATCH|DELETE {url}/items/{collection}/{id}`,
4
+ * `GET|POST {url}/items/{collection}` (`readOne` is a `filter[<key>][_eq]` list query with `limit=1`). Ids are
5
+ * URL-encoded (`gutachten:<uuid>` holds a colon). Any answer other than 2xx throws, except where a member says otherwise.
6
+ */
7
+ function restItemsClient({ url, token, fetch: fetchImpl = fetch }) {
8
+ const base = url.replace(/\/+$/, "");
9
+ const collectionUrl = (collection) => `${base}/items/${encodeURIComponent(collection)}`;
10
+ const itemUrl = (collection, id) => `${collectionUrl(collection)}/${encodeURIComponent(id)}`;
11
+ async function send(method, target, body) {
12
+ return fetchImpl(target, {
13
+ method,
14
+ headers: {
15
+ Authorization: `Bearer ${token}`,
16
+ Accept: "application/json",
17
+ ...body !== void 0 && { "Content-Type": "application/json" }
18
+ },
19
+ ...body !== void 0 && { body: JSON.stringify(body) }
20
+ });
21
+ }
22
+ async function fail(method, target, response) {
23
+ const detail = await response.text().catch(() => "");
24
+ throw new Error(`@skaile/forge-editor-directus: ${method} ${target} answered ${response.status}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
25
+ }
26
+ async function dataOf(response) {
27
+ return (await response.json()).data;
28
+ }
29
+ const fieldsQuery = (fields) => fields?.length ? `fields=${fields.map(encodeURIComponent).join(",")}` : "";
30
+ return {
31
+ async readOne(collection, id, fields, primaryKey = "id") {
32
+ const query = [
33
+ `filter[${encodeURIComponent(primaryKey)}][_eq]=${encodeURIComponent(id)}`,
34
+ fieldsQuery(fields),
35
+ "limit=1"
36
+ ].filter(Boolean).join("&");
37
+ const target = `${collectionUrl(collection)}?${query}`;
38
+ const response = await send("GET", target);
39
+ if (!response.ok) return fail("GET", target, response);
40
+ const rows = await dataOf(response);
41
+ return Array.isArray(rows) ? rows[0] ?? null : null;
42
+ },
43
+ async readMany(collection, query) {
44
+ const target = `${collectionUrl(collection)}?${[fieldsQuery(query?.fields), "limit=-1"].filter(Boolean).join("&")}`;
45
+ const response = await send("GET", target);
46
+ if (!response.ok) return fail("GET", target, response);
47
+ return await dataOf(response) ?? [];
48
+ },
49
+ async updateOne(collection, id, patch, primaryKey = "id") {
50
+ const target = `${itemUrl(collection, id)}?fields=${encodeURIComponent(primaryKey)}`;
51
+ const response = await send("PATCH", target, patch);
52
+ if (response.status === 404 || response.status === 204) return false;
53
+ if (!response.ok) return fail("PATCH", target, response);
54
+ const text = await response.text();
55
+ if (!text) return false;
56
+ return (JSON.parse(text).data ?? null) !== null;
57
+ },
58
+ async createOne(collection, row) {
59
+ const target = collectionUrl(collection);
60
+ const response = await send("POST", target, row);
61
+ if (!response.ok) return fail("POST", target, response);
62
+ },
63
+ async deleteOne(collection, id) {
64
+ const target = itemUrl(collection, id);
65
+ const response = await send("DELETE", target);
66
+ if (!response.ok && response.status !== 404) return fail("DELETE", target, response);
67
+ }
68
+ };
69
+ }
70
+ //#endregion
71
+ export { restItemsClient };
72
+
73
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","names":[],"sources":["../src/client.ts"],"sourcesContent":["export type DirectusRow = Record<string, unknown>\n\n/** The slice of the Directus items API the adapter needs; inject a fake in tests. */\nexport interface DirectusItemsClient {\n /**\n * One item by primary key, `null` when there is none. Read through a filtered list query (`filter[<key>][_eq]`), not\n * `GET /items/<c>/<id>`: Directus 11 answers 403 (not 404) for a missing item, even to an admin token.\n * `primaryKey` names the key column, default `id`.\n */\n readOne(collection: string, id: string, fields?: string[], primaryKey?: string): Promise<DirectusRow | null>\n /** Every item (`limit=-1`: Directus cuts an unlimited query to 100 otherwise). */\n readMany(collection: string, query?: { fields?: string[] }): Promise<DirectusRow[]>\n /**\n * Patches one item; `false` = there is no such item. Directus 11 answers PATCH of a missing id with 204 and no\n * body (nothing is created), PATCH of an existing one with 200 and the item; 404 is read as missing as well.\n * `primaryKey` names the key column, default `id`.\n */\n updateOne(collection: string, id: string, patch: DirectusRow, primaryKey?: string): Promise<boolean>\n createOne(collection: string, row: DirectusRow): Promise<void>\n /** Deletes one item; a missing item (204 or 404) is fine. */\n deleteOne(collection: string, id: string): Promise<void>\n}\n\nexport interface RestItemsClientOptions {\n /** Base URL of the Directus instance, e.g. `http://localhost:8055`. */\n url: string\n /** Static token (`Authorization: Bearer`). Directus answers a missing item with 403, not 404, and a 403 is an error here: reads go through a filtered list query, so use an admin token or full access to the collections. */\n token: string\n fetch?: typeof fetch\n}\n\n/**\n * The Directus items client over `fetch` (no `@directus/sdk`): `PATCH|DELETE {url}/items/{collection}/{id}`,\n * `GET|POST {url}/items/{collection}` (`readOne` is a `filter[<key>][_eq]` list query with `limit=1`). Ids are\n * URL-encoded (`gutachten:<uuid>` holds a colon). Any answer other than 2xx throws, except where a member says otherwise.\n */\nexport function restItemsClient({ url, token, fetch: fetchImpl = fetch }: RestItemsClientOptions): DirectusItemsClient {\n const base = url.replace(/\\/+$/, '')\n const collectionUrl = (collection: string) => `${base}/items/${encodeURIComponent(collection)}`\n const itemUrl = (collection: string, id: string) => `${collectionUrl(collection)}/${encodeURIComponent(id)}`\n\n async function send(method: string, target: string, body?: unknown): Promise<Response> {\n return fetchImpl(target, {\n method,\n headers: {\n Authorization: `Bearer ${token}`,\n Accept: 'application/json',\n ...(body !== undefined && { 'Content-Type': 'application/json' }),\n },\n ...(body !== undefined && { body: JSON.stringify(body) }),\n })\n }\n\n async function fail(method: string, target: string, response: Response): Promise<never> {\n const detail = await response.text().catch(() => '')\n throw new Error(`@skaile/forge-editor-directus: ${method} ${target} answered ${response.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`)\n }\n\n async function dataOf(response: Response): Promise<unknown> {\n return ((await response.json()) as { data?: unknown }).data\n }\n\n const fieldsQuery = (fields?: string[]) => fields?.length ? `fields=${fields.map(encodeURIComponent).join(',')}` : ''\n\n return {\n async readOne(collection, id, fields, primaryKey = 'id') {\n const query = [`filter[${encodeURIComponent(primaryKey)}][_eq]=${encodeURIComponent(id)}`, fieldsQuery(fields), 'limit=1'].filter(Boolean).join('&')\n const target = `${collectionUrl(collection)}?${query}`\n const response = await send('GET', target)\n if (!response.ok) return fail('GET', target, response)\n const rows = await dataOf(response)\n return (Array.isArray(rows) ? rows[0] ?? null : null) as DirectusRow | null\n },\n async readMany(collection, query) {\n const target = `${collectionUrl(collection)}?${[fieldsQuery(query?.fields), 'limit=-1'].filter(Boolean).join('&')}`\n const response = await send('GET', target)\n if (!response.ok) return fail('GET', target, response)\n return ((await dataOf(response)) ?? []) as DirectusRow[]\n },\n async updateOne(collection, id, patch, primaryKey = 'id') {\n const target = `${itemUrl(collection, id)}?fields=${encodeURIComponent(primaryKey)}`\n const response = await send('PATCH', target, patch)\n if (response.status === 404 || response.status === 204) return false\n if (!response.ok) return fail('PATCH', target, response)\n const text = await response.text()\n if (!text) return false\n return ((JSON.parse(text) as { data?: unknown }).data ?? null) !== null\n },\n async createOne(collection, row) {\n const target = collectionUrl(collection)\n const response = await send('POST', target, row)\n if (!response.ok) return fail('POST', target, response)\n },\n async deleteOne(collection, id) {\n const target = itemUrl(collection, id)\n const response = await send('DELETE', target)\n if (!response.ok && response.status !== 404) return fail('DELETE', target, response)\n },\n }\n}\n"],"mappings":"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"}
package/dist/hooks.js ADDED
@@ -0,0 +1,32 @@
1
+ //#region src/hooks.ts
2
+ const DEV_USER = {
3
+ id: "dev",
4
+ username: "dev",
5
+ color: "#6366f1"
6
+ };
7
+ const authenticateDefault = async () => process.env.NODE_ENV === "production" ? null : { ...DEV_USER };
8
+ /** The `identity` port of `hooks`; without `authenticate` a development user outside `NODE_ENV=production`, nobody in production. `authorize` is only present when given. */
9
+ function identityOf(hooks) {
10
+ const { authenticate = authenticateDefault, authorize } = hooks;
11
+ return {
12
+ authenticate: (request) => authenticate(request),
13
+ ...authorize && { authorize: (user, id) => authorize(user, id) }
14
+ };
15
+ }
16
+ /** The `directory` port of the `users` hook. */
17
+ function directoryOf({ users }) {
18
+ return { listUsers: async () => await users?.() ?? [] };
19
+ }
20
+ /** The key per-user settings are stored under: the user's `id`, else its `username`. */
21
+ function settingsKey(user) {
22
+ const { id, username } = user;
23
+ for (const candidate of [id, username]) {
24
+ if (typeof candidate === "string" && candidate !== "") return candidate;
25
+ if (typeof candidate === "number") return String(candidate);
26
+ }
27
+ throw new TypeError("@skaile/forge-editor-directus: the authenticated user has neither an `id` nor a `username`, so its settings have no key");
28
+ }
29
+ //#endregion
30
+ export { directoryOf, identityOf, settingsKey };
31
+
32
+ //# sourceMappingURL=hooks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks.js","names":[],"sources":["../src/hooks.ts"],"sourcesContent":["import type { AdapterHooks } from '@skaile/forge-editor/adapters'\nimport type { AccessControl, Directory } from '@skaile/forge-editor/server'\n\n// A copy of the identity half of `@skaile/forge-editor/adapters` (`adapters/shared.ts`), which does not export it. The\n// contract test (`test/contract.test.ts`) runs both implementations side by side, so a drift shows up there.\n\nconst DEV_USER = { id: 'dev', username: 'dev', color: '#6366f1' } as const\n\nconst authenticateDefault: AccessControl['authenticate'] = async () => process.env.NODE_ENV === 'production' ? null : { ...DEV_USER }\n\n/** The `identity` port of `hooks`; without `authenticate` a development user outside `NODE_ENV=production`, nobody in production. `authorize` is only present when given. */\nexport function identityOf(hooks: Pick<AdapterHooks, 'authenticate' | 'authorize'>): AccessControl {\n const { authenticate = authenticateDefault, authorize } = hooks\n return {\n authenticate: request => authenticate(request),\n ...(authorize && { authorize: (user: object, id: string) => authorize(user, id) }),\n }\n}\n\n/** The `directory` port of the `users` hook. */\nexport function directoryOf({ users }: Pick<AdapterHooks, 'users'>): Directory {\n return { listUsers: async () => (await users?.()) ?? [] }\n}\n\n/** The key per-user settings are stored under: the user's `id`, else its `username`. */\nexport function settingsKey(user: object): string {\n const { id, username } = user as { id?: unknown, username?: unknown }\n for (const candidate of [id, username]) {\n if (typeof candidate === 'string' && candidate !== '') return candidate\n if (typeof candidate === 'number') return String(candidate)\n }\n throw new TypeError('@skaile/forge-editor-directus: the authenticated user has neither an `id` nor a `username`, so its settings have no key')\n}\n"],"mappings":";AAMA,MAAM,WAAW;CAAE,IAAI;CAAO,UAAU;CAAO,OAAO;AAAU;AAEhE,MAAM,sBAAqD,YAAY,QAAQ,IAAI,aAAa,eAAe,OAAO,EAAE,GAAG,SAAS;;AAGpI,SAAgB,WAAW,OAAwE;CACjG,MAAM,EAAE,eAAe,qBAAqB,cAAc;CAC1D,OAAO;EACL,eAAc,YAAW,aAAa,OAAO;EAC7C,GAAI,aAAa,EAAE,YAAY,MAAc,OAAe,UAAU,MAAM,EAAE,EAAE;CAClF;AACF;;AAGA,SAAgB,YAAY,EAAE,SAAiD;CAC7E,OAAO,EAAE,WAAW,YAAa,MAAM,QAAQ,KAAM,CAAC,EAAE;AAC1D;;AAGA,SAAgB,YAAY,MAAsB;CAChD,MAAM,EAAE,IAAI,aAAa;CACzB,KAAK,MAAM,aAAa,CAAC,IAAI,QAAQ,GAAG;EACtC,IAAI,OAAO,cAAc,YAAY,cAAc,IAAI,OAAO;EAC9D,IAAI,OAAO,cAAc,UAAU,OAAO,OAAO,SAAS;CAC5D;CACA,MAAM,IAAI,UAAU,yHAAyH;AAC/I"}
@@ -0,0 +1,3 @@
1
+ import { DirectusItemsClient, DirectusRow, RestItemsClientOptions, restItemsClient } from "./client.js";
2
+ import { DirectusAdapterOptions, DirectusPorts, createDirectusAdapter } from "./adapter.js";
3
+ export { type DirectusAdapterOptions, type DirectusItemsClient, type DirectusPorts, type DirectusRow, type RestItemsClientOptions, createDirectusAdapter, restItemsClient };
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { restItemsClient } from "./client.js";
2
+ import { createDirectusAdapter } from "./adapter.js";
3
+ export { createDirectusAdapter, restItemsClient };
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@skaile/forge-editor-directus",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Directus storage for @skaile/forge-editor: documents, collaboration state and per-user settings over the Directus REST items API (fetch only, no Directus SDK).",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/skaile-ai/forge-editor.git",
10
+ "directory": "packages/editor-directus"
11
+ },
12
+ "homepage": "https://github.com/skaile-ai/forge-editor/tree/main/packages/editor-directus#readme",
13
+ "bugs": "https://github.com/skaile-ai/forge-editor/issues",
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "CHANGELOG.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "build": "tsdown",
24
+ "test": "bun test --conditions skaile-source --preload ../editor/test-setup.ts test"
25
+ },
26
+ "exports": {
27
+ ".": {
28
+ "skaile-source": "./src/index.ts",
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ }
32
+ },
33
+ "peerDependencies": {
34
+ "@skaile/forge-editor": "^6.2.0"
35
+ },
36
+ "devDependencies": {
37
+ "@skaile/forge-editor": "workspace:*",
38
+ "tsdown": "^0.23.1"
39
+ }
40
+ }