@nymbalhq/doc-store-postgres-jsonb 0.0.0-stage → 1.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.
@@ -0,0 +1,52 @@
1
+ import { DocumentStoreAdapter, Logger, HealthReport, PutOptions, QueryParams, QueryResult, AdapterContext } from '@nymbalhq/types';
2
+
3
+ interface PostgresJsonbDocumentStoreOptions {
4
+ /** The command store's database: the table lives beside the kernel's own. */
5
+ postgresUrl: string;
6
+ /** Namespaces every row the store writes, so stores sharing a database never see each other's documents. */
7
+ prefix: string;
8
+ logger?: Logger;
9
+ /** How often expired rows are deleted (they are never returned once expired). 0 turns the sweep off. */
10
+ sweepIntervalMs?: number;
11
+ now?: () => number;
12
+ }
13
+ /**
14
+ * A document store on Postgres JSONB: one table, `nymbal_documents`, owned by the store and created by `initialize()`.
15
+ * It holds derived data the kernel rebuilds at every start, so it is not a kernel migration.
16
+ */
17
+ declare class PostgresJsonbDocumentStore implements DocumentStoreAdapter {
18
+ #private;
19
+ constructor(options: PostgresJsonbDocumentStoreOptions);
20
+ capabilities(): readonly string[];
21
+ /** Creates the table and its indexes when absent, under an advisory lock; safe to call again. */
22
+ initialize(): Promise<void>;
23
+ /**
24
+ * One read on a connection of its own, ended afterwards, so a server that cannot be reached leaves no connect
25
+ * attempt behind in the pool. `degraded` while the table `initialize()` creates is absent.
26
+ */
27
+ healthCheck(): Promise<HealthReport>;
28
+ get<T>(collection: string, key: string): Promise<T | null>;
29
+ put<T>(collection: string, key: string, document: T, options?: PutOptions): Promise<void>;
30
+ delete(collection: string, key: string): Promise<void>;
31
+ query<T>(collection: string, params: QueryParams): Promise<QueryResult<T>>;
32
+ batchGet<T>(collection: string, keys: string[]): Promise<Map<string, T>>;
33
+ batchPut<T>(collection: string, items: Map<string, T>): Promise<void>;
34
+ /**
35
+ * One transaction: the collection's rows are deleted and the new ones inserted, so a reader (on another connection)
36
+ * sees the old rows until the commit and the new ones after it. A source that throws rolls it back.
37
+ */
38
+ replaceCollection<T>(collection: string, entries: AsyncIterable<[string, T]>): Promise<void>;
39
+ /** Deletes this prefix's expired rows. Reads never return them in the meantime. */
40
+ sweep(): Promise<void>;
41
+ close(): Promise<void>;
42
+ }
43
+ declare function createPostgresJsonbDocumentStore(options: PostgresJsonbDocumentStoreOptions): PostgresJsonbDocumentStore;
44
+
45
+ /**
46
+ * The kernel's entry: `infrastructure.documentStore: 'postgres-jsonb'`, on the command store's connection
47
+ * (`config.postgresUrl`, then `DATABASE_URL`). The kernel calls `initialize()` before it uses the store.
48
+ */
49
+ declare function createAdapter(ctx: AdapterContext): PostgresJsonbDocumentStore;
50
+
51
+ export { PostgresJsonbDocumentStore, createAdapter, createPostgresJsonbDocumentStore };
52
+ export type { PostgresJsonbDocumentStoreOptions };
package/dist/index.js ADDED
@@ -0,0 +1,295 @@
1
+ // src/index.ts
2
+ import { ConfigError } from "@nymbalhq/types";
3
+
4
+ // src/postgres-jsonb-document-store.ts
5
+ import pg from "pg";
6
+ import {
7
+ AdapterError,
8
+ boundedHealthCheck,
9
+ connectionSecrets,
10
+ HEALTH_CHECK_TIMEOUT_MS,
11
+ queryDocuments
12
+ } from "@nymbalhq/types";
13
+ var TABLE = "nymbal_documents";
14
+ var DDL_LOCK = 1853451618;
15
+ var DDL = [
16
+ `CREATE TABLE IF NOT EXISTS ${TABLE} (
17
+ prefix text NOT NULL,
18
+ collection text NOT NULL,
19
+ key text NOT NULL,
20
+ doc jsonb NOT NULL,
21
+ expires_at timestamptz,
22
+ PRIMARY KEY (prefix, collection, key)
23
+ )`,
24
+ // Narrows a partition query (`doc @> {"field": value}`) without a scan of the collection.
25
+ `CREATE INDEX IF NOT EXISTS ${TABLE}_doc_idx ON ${TABLE} USING gin (doc jsonb_path_ops)`,
26
+ `CREATE INDEX IF NOT EXISTS ${TABLE}_expires_idx ON ${TABLE} (expires_at) WHERE expires_at IS NOT NULL`
27
+ ];
28
+ var LIVE = "(expires_at IS NULL OR expires_at > $NOW)";
29
+ var BATCH_ROWS = 500;
30
+ function narrowable(value) {
31
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
32
+ }
33
+ var PostgresJsonbDocumentStore = class {
34
+ #pool;
35
+ #url;
36
+ #prefix;
37
+ #now;
38
+ #logger;
39
+ #sweepIntervalMs;
40
+ #sweepTimer = null;
41
+ constructor(options) {
42
+ this.#pool = new pg.Pool({ connectionString: options.postgresUrl, max: 5 });
43
+ this.#pool.on("error", (err) => options.logger?.warn({ err }, "postgres jsonb pool error"));
44
+ this.#url = options.postgresUrl;
45
+ this.#prefix = options.prefix;
46
+ this.#now = options.now ?? Date.now;
47
+ this.#logger = options.logger;
48
+ this.#sweepIntervalMs = options.sweepIntervalMs ?? 6e4;
49
+ }
50
+ capabilities() {
51
+ return ["get", "put", "delete", "query", "batch", "ttl", "conditional-put"];
52
+ }
53
+ /** Creates the table and its indexes when absent, under an advisory lock; safe to call again. */
54
+ async initialize() {
55
+ const client = await this.#pool.connect();
56
+ try {
57
+ await client.query("BEGIN");
58
+ await client.query("SELECT pg_advisory_xact_lock($1)", [DDL_LOCK]);
59
+ for (const statement of DDL) await client.query(statement);
60
+ await client.query("COMMIT");
61
+ } catch (error) {
62
+ await client.query("ROLLBACK").catch(() => void 0);
63
+ throw error;
64
+ } finally {
65
+ client.release();
66
+ }
67
+ if (this.#sweepTimer === null && this.#sweepIntervalMs > 0) {
68
+ this.#sweepTimer = setInterval(() => void this.sweep(), this.#sweepIntervalMs);
69
+ this.#sweepTimer.unref();
70
+ }
71
+ }
72
+ /**
73
+ * One read on a connection of its own, ended afterwards, so a server that cannot be reached leaves no connect
74
+ * attempt behind in the pool. `degraded` while the table `initialize()` creates is absent.
75
+ */
76
+ healthCheck() {
77
+ const client = new pg.Client({
78
+ connectionString: this.#url,
79
+ connectionTimeoutMillis: HEALTH_CHECK_TIMEOUT_MS
80
+ });
81
+ client.on("error", () => void 0);
82
+ return boundedHealthCheck(
83
+ async () => {
84
+ await client.connect();
85
+ const { rows } = await client.query(
86
+ `SELECT to_regclass('${TABLE}')::text AS table`
87
+ );
88
+ return rows[0]?.table == null ? { status: "degraded", detail: "not initialized: run nymbal dev or migrate" } : { status: "healthy" };
89
+ },
90
+ { redact: connectionSecrets(this.#url) }
91
+ ).finally(
92
+ () => (
93
+ // A probe that never connected has nothing to end; its failure is already the report.
94
+ client.end().catch(() => void 0)
95
+ )
96
+ );
97
+ }
98
+ async get(collection, key) {
99
+ const { rows } = await this.#pool.query(
100
+ `SELECT doc FROM ${TABLE} WHERE prefix = $1 AND collection = $2 AND key = $3 AND ${this.#live(4)}`,
101
+ [this.#prefix, collection, key, this.#nowDate()]
102
+ );
103
+ return rows[0]?.doc ?? null;
104
+ }
105
+ async put(collection, key, document, options = {}) {
106
+ const expiresAt = options.ttl !== void 0 ? new Date(this.#now() + options.ttl * 1e3) : null;
107
+ const doc = JSON.stringify(document);
108
+ if (options.conditionKey === void 0) {
109
+ await this.#pool.query(
110
+ `INSERT INTO ${TABLE} (prefix, collection, key, doc, expires_at) VALUES ($1, $2, $3, $4::jsonb, $5)
111
+ ON CONFLICT (prefix, collection, key) DO UPDATE SET doc = EXCLUDED.doc, expires_at = EXCLUDED.expires_at`,
112
+ [this.#prefix, collection, key, doc, expiresAt]
113
+ );
114
+ return;
115
+ }
116
+ const conditionKey = options.conditionKey;
117
+ const client = await this.#pool.connect();
118
+ try {
119
+ await client.query("BEGIN");
120
+ const { rows } = await client.query(
121
+ `SELECT doc FROM ${TABLE} WHERE prefix = $1 AND collection = $2 AND key = $3 AND ${this.#live(4)}
122
+ FOR UPDATE`,
123
+ [this.#prefix, collection, key, this.#nowDate()]
124
+ );
125
+ const current = rows[0]?.doc;
126
+ const holds = current !== void 0 && current[conditionKey] === options.conditionValue;
127
+ if (!holds) {
128
+ await client.query("ROLLBACK");
129
+ throw new AdapterError(
130
+ "condition-failed",
131
+ current === void 0 ? `Condition failed on ${collection}/${key}: document does not exist` : `Condition failed on ${collection}/${key}: ${conditionKey} mismatch`,
132
+ { context: { collection, key, conditionKey } }
133
+ );
134
+ }
135
+ await client.query(
136
+ `UPDATE ${TABLE} SET doc = $4::jsonb, expires_at = $5 WHERE prefix = $1 AND collection = $2 AND key = $3`,
137
+ [this.#prefix, collection, key, doc, expiresAt]
138
+ );
139
+ await client.query("COMMIT");
140
+ } catch (error) {
141
+ if (!(error instanceof AdapterError)) await client.query("ROLLBACK").catch(() => void 0);
142
+ throw error;
143
+ } finally {
144
+ client.release();
145
+ }
146
+ }
147
+ async delete(collection, key) {
148
+ await this.#pool.query(
149
+ `DELETE FROM ${TABLE} WHERE prefix = $1 AND collection = $2 AND key = $3`,
150
+ [this.#prefix, collection, key]
151
+ );
152
+ }
153
+ async query(collection, params) {
154
+ const { field, value } = params.partitionKey;
155
+ const narrow = value !== "*" && narrowable(value);
156
+ const { rows } = await this.#pool.query(
157
+ `SELECT key, doc FROM ${TABLE} WHERE prefix = $1 AND collection = $2 AND ${this.#live(3)}` + (narrow ? " AND doc @> $4::jsonb" : ""),
158
+ [
159
+ this.#prefix,
160
+ collection,
161
+ this.#nowDate(),
162
+ ...narrow ? [JSON.stringify({ [field]: value })] : []
163
+ ]
164
+ );
165
+ return queryDocuments(
166
+ rows.map((r) => [r.key, r.doc]),
167
+ params
168
+ );
169
+ }
170
+ async batchGet(collection, keys) {
171
+ const result = /* @__PURE__ */ new Map();
172
+ if (keys.length === 0) return result;
173
+ const { rows } = await this.#pool.query(
174
+ `SELECT key, doc FROM ${TABLE} WHERE prefix = $1 AND collection = $2 AND key = ANY($3::text[]) AND ${this.#live(4)}`,
175
+ [this.#prefix, collection, keys, this.#nowDate()]
176
+ );
177
+ const byKey = new Map(rows.map((r) => [r.key, r.doc]));
178
+ for (const key of keys) {
179
+ const doc = byKey.get(key);
180
+ if (doc !== void 0) result.set(key, doc);
181
+ }
182
+ return result;
183
+ }
184
+ async batchPut(collection, items) {
185
+ const entries = [...items];
186
+ for (let i = 0; i < entries.length; i += BATCH_ROWS) {
187
+ const chunk = entries.slice(i, i + BATCH_ROWS);
188
+ await this.#pool.query(
189
+ `INSERT INTO ${TABLE} (prefix, collection, key, doc, expires_at)
190
+ SELECT $1, $2, k, d, NULL FROM unnest($3::text[], $4::jsonb[]) AS t(k, d)
191
+ ON CONFLICT (prefix, collection, key) DO UPDATE SET doc = EXCLUDED.doc, expires_at = NULL`,
192
+ [
193
+ this.#prefix,
194
+ collection,
195
+ chunk.map(([key]) => key),
196
+ chunk.map(([, doc]) => JSON.stringify(doc))
197
+ ]
198
+ );
199
+ }
200
+ }
201
+ /**
202
+ * One transaction: the collection's rows are deleted and the new ones inserted, so a reader (on another connection)
203
+ * sees the old rows until the commit and the new ones after it. A source that throws rolls it back.
204
+ */
205
+ async replaceCollection(collection, entries) {
206
+ const client = await this.#pool.connect();
207
+ try {
208
+ await client.query("BEGIN");
209
+ await client.query(`DELETE FROM ${TABLE} WHERE prefix = $1 AND collection = $2`, [
210
+ this.#prefix,
211
+ collection
212
+ ]);
213
+ let chunk = /* @__PURE__ */ new Map();
214
+ const flush = async () => {
215
+ if (chunk.size === 0) return;
216
+ await client.query(
217
+ `INSERT INTO ${TABLE} (prefix, collection, key, doc, expires_at)
218
+ SELECT $1, $2, k, d, NULL FROM unnest($3::text[], $4::jsonb[]) AS t(k, d)
219
+ ON CONFLICT (prefix, collection, key) DO UPDATE SET doc = EXCLUDED.doc, expires_at = NULL`,
220
+ [this.#prefix, collection, [...chunk.keys()], [...chunk.values()]]
221
+ );
222
+ chunk = /* @__PURE__ */ new Map();
223
+ };
224
+ for await (const [key, doc] of entries) {
225
+ chunk.set(key, JSON.stringify(doc));
226
+ if (chunk.size >= BATCH_ROWS) await flush();
227
+ }
228
+ await flush();
229
+ await client.query("COMMIT");
230
+ } catch (error) {
231
+ await client.query("ROLLBACK").catch(() => void 0);
232
+ throw error;
233
+ } finally {
234
+ client.release();
235
+ }
236
+ }
237
+ /** Deletes this prefix's expired rows. Reads never return them in the meantime. */
238
+ async sweep() {
239
+ try {
240
+ await this.#pool.query(`DELETE FROM ${TABLE} WHERE prefix = $1 AND expires_at <= $2`, [
241
+ this.#prefix,
242
+ this.#nowDate()
243
+ ]);
244
+ } catch (err) {
245
+ this.#logger?.warn({ err }, "postgres jsonb document store: expired rows were not swept");
246
+ }
247
+ }
248
+ async close() {
249
+ if (this.#sweepTimer !== null) clearInterval(this.#sweepTimer);
250
+ this.#sweepTimer = null;
251
+ await this.#pool.end();
252
+ }
253
+ /** The not-expired condition, with "now" as parameter `$n`: the store's clock, not the server's. */
254
+ #live(n) {
255
+ return LIVE.replace("$NOW", `$${n}`);
256
+ }
257
+ #nowDate() {
258
+ return new Date(this.#now());
259
+ }
260
+ };
261
+ function createPostgresJsonbDocumentStore(options) {
262
+ return new PostgresJsonbDocumentStore(options);
263
+ }
264
+
265
+ // src/index.ts
266
+ function createAdapter(ctx) {
267
+ if (ctx.slot !== "documentStore") {
268
+ throw new ConfigError(
269
+ `@nymbalhq/doc-store-postgres-jsonb serves documentStore, not ${ctx.slot}`,
270
+ { code: "adapter.wrong_slot", context: { slot: ctx.slot } }
271
+ );
272
+ }
273
+ const configured = ctx.config["postgresUrl"];
274
+ const postgresUrl = typeof configured === "string" && configured !== "" ? configured : ctx.env["DATABASE_URL"];
275
+ const prefix = ctx.config["prefix"];
276
+ if (postgresUrl === void 0 || postgresUrl === "") {
277
+ throw new ConfigError(
278
+ "@nymbalhq/doc-store-postgres-jsonb needs the Postgres connection: set DATABASE_URL",
279
+ { code: "config.postgres_url_missing", context: { slot: ctx.slot } }
280
+ );
281
+ }
282
+ if (typeof prefix !== "string" || prefix === "") {
283
+ throw new ConfigError("@nymbalhq/doc-store-postgres-jsonb needs a prefix", {
284
+ code: "config.invalid",
285
+ context: { slot: ctx.slot }
286
+ });
287
+ }
288
+ return createPostgresJsonbDocumentStore({ postgresUrl, prefix, logger: ctx.logger });
289
+ }
290
+ export {
291
+ PostgresJsonbDocumentStore,
292
+ createAdapter,
293
+ createPostgresJsonbDocumentStore
294
+ };
295
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/postgres-jsonb-document-store.ts"],"sourcesContent":["import { ConfigError, type AdapterContext } from '@nymbalhq/types'\nimport {\n createPostgresJsonbDocumentStore,\n PostgresJsonbDocumentStore,\n type PostgresJsonbDocumentStoreOptions,\n} from './postgres-jsonb-document-store.js'\n\nexport {\n createPostgresJsonbDocumentStore,\n PostgresJsonbDocumentStore,\n type PostgresJsonbDocumentStoreOptions,\n}\n\n/**\n * The kernel's entry: `infrastructure.documentStore: 'postgres-jsonb'`, on the command store's connection\n * (`config.postgresUrl`, then `DATABASE_URL`). The kernel calls `initialize()` before it uses the store.\n */\nexport function createAdapter(ctx: AdapterContext): PostgresJsonbDocumentStore {\n if (ctx.slot !== 'documentStore') {\n throw new ConfigError(\n `@nymbalhq/doc-store-postgres-jsonb serves documentStore, not ${ctx.slot}`,\n { code: 'adapter.wrong_slot', context: { slot: ctx.slot } },\n )\n }\n const configured = ctx.config['postgresUrl']\n const postgresUrl =\n typeof configured === 'string' && configured !== '' ? configured : ctx.env['DATABASE_URL']\n const prefix = ctx.config['prefix']\n if (postgresUrl === undefined || postgresUrl === '') {\n throw new ConfigError(\n '@nymbalhq/doc-store-postgres-jsonb needs the Postgres connection: set DATABASE_URL',\n { code: 'config.postgres_url_missing', context: { slot: ctx.slot } },\n )\n }\n if (typeof prefix !== 'string' || prefix === '') {\n throw new ConfigError('@nymbalhq/doc-store-postgres-jsonb needs a prefix', {\n code: 'config.invalid',\n context: { slot: ctx.slot },\n })\n }\n return createPostgresJsonbDocumentStore({ postgresUrl, prefix, logger: ctx.logger })\n}\n","import pg from 'pg'\nimport {\n AdapterError,\n boundedHealthCheck,\n connectionSecrets,\n HEALTH_CHECK_TIMEOUT_MS,\n queryDocuments,\n type DocumentStoreAdapter,\n type HealthReport,\n type Logger,\n type PutOptions,\n type QueryParams,\n type QueryResult,\n} from '@nymbalhq/types'\n\nexport interface PostgresJsonbDocumentStoreOptions {\n /** The command store's database: the table lives beside the kernel's own. */\n postgresUrl: string\n /** Namespaces every row the store writes, so stores sharing a database never see each other's documents. */\n prefix: string\n logger?: Logger\n /** How often expired rows are deleted (they are never returned once expired). 0 turns the sweep off. */\n sweepIntervalMs?: number\n now?: () => number\n}\n\nconst TABLE = 'nymbal_documents'\n/** Serialises the table's DDL across replicas that start together: any constant every replica agrees on. */\nconst DDL_LOCK = 0x6e796d62\n\nconst DDL = [\n `CREATE TABLE IF NOT EXISTS ${TABLE} (\n prefix text NOT NULL,\n collection text NOT NULL,\n key text NOT NULL,\n doc jsonb NOT NULL,\n expires_at timestamptz,\n PRIMARY KEY (prefix, collection, key)\n )`,\n // Narrows a partition query (`doc @> {\"field\": value}`) without a scan of the collection.\n `CREATE INDEX IF NOT EXISTS ${TABLE}_doc_idx ON ${TABLE} USING gin (doc jsonb_path_ops)`,\n `CREATE INDEX IF NOT EXISTS ${TABLE}_expires_idx ON ${TABLE} (expires_at) WHERE expires_at IS NOT NULL`,\n]\n\nconst LIVE = '(expires_at IS NULL OR expires_at > $NOW)'\nconst BATCH_ROWS = 500\n\n/** A partition value the GIN index can narrow by: the store still filters with `===` afterwards. */\nfunction narrowable(value: unknown): boolean {\n return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n}\n\n/**\n * A document store on Postgres JSONB: one table, `nymbal_documents`, owned by the store and created by `initialize()`.\n * It holds derived data the kernel rebuilds at every start, so it is not a kernel migration.\n */\nexport class PostgresJsonbDocumentStore implements DocumentStoreAdapter {\n readonly #pool: pg.Pool\n readonly #url: string\n readonly #prefix: string\n readonly #now: () => number\n readonly #logger: Logger | undefined\n readonly #sweepIntervalMs: number\n #sweepTimer: NodeJS.Timeout | null = null\n\n constructor(options: PostgresJsonbDocumentStoreOptions) {\n this.#pool = new pg.Pool({ connectionString: options.postgresUrl, max: 5 })\n this.#pool.on('error', (err) => options.logger?.warn({ err }, 'postgres jsonb pool error'))\n this.#url = options.postgresUrl\n this.#prefix = options.prefix\n this.#now = options.now ?? Date.now\n this.#logger = options.logger\n this.#sweepIntervalMs = options.sweepIntervalMs ?? 60_000\n }\n\n capabilities(): readonly string[] {\n return ['get', 'put', 'delete', 'query', 'batch', 'ttl', 'conditional-put']\n }\n\n /** Creates the table and its indexes when absent, under an advisory lock; safe to call again. */\n async initialize(): Promise<void> {\n const client = await this.#pool.connect()\n try {\n await client.query('BEGIN')\n await client.query('SELECT pg_advisory_xact_lock($1)', [DDL_LOCK])\n for (const statement of DDL) await client.query(statement)\n await client.query('COMMIT')\n } catch (error) {\n await client.query('ROLLBACK').catch(() => undefined)\n throw error\n } finally {\n client.release()\n }\n if (this.#sweepTimer === null && this.#sweepIntervalMs > 0) {\n this.#sweepTimer = setInterval(() => void this.sweep(), this.#sweepIntervalMs)\n this.#sweepTimer.unref()\n }\n }\n\n /**\n * One read on a connection of its own, ended afterwards, so a server that cannot be reached leaves no connect\n * attempt behind in the pool. `degraded` while the table `initialize()` creates is absent.\n */\n healthCheck(): Promise<HealthReport> {\n const client = new pg.Client({\n connectionString: this.#url,\n connectionTimeoutMillis: HEALTH_CHECK_TIMEOUT_MS,\n })\n // connect() rejects with the same error, and the report carries it; without a listener pg would throw it.\n client.on('error', () => undefined)\n return boundedHealthCheck(\n async () => {\n await client.connect()\n const { rows } = await client.query<{ table: string | null }>(\n `SELECT to_regclass('${TABLE}')::text AS table`,\n )\n return rows[0]?.table == null\n ? { status: 'degraded', detail: 'not initialized: run nymbal dev or migrate' }\n : { status: 'healthy' }\n },\n { redact: connectionSecrets(this.#url) },\n ).finally(() =>\n // A probe that never connected has nothing to end; its failure is already the report.\n client.end().catch(() => undefined),\n )\n }\n\n async get<T>(collection: string, key: string): Promise<T | null> {\n const { rows } = await this.#pool.query<{ doc: T }>(\n `SELECT doc FROM ${TABLE} WHERE prefix = $1 AND collection = $2 AND key = $3 AND ${this.#live(4)}`,\n [this.#prefix, collection, key, this.#nowDate()],\n )\n return rows[0]?.doc ?? null\n }\n\n async put<T>(\n collection: string,\n key: string,\n document: T,\n options: PutOptions = {},\n ): Promise<void> {\n const expiresAt = options.ttl !== undefined ? new Date(this.#now() + options.ttl * 1000) : null\n const doc = JSON.stringify(document)\n if (options.conditionKey === undefined) {\n await this.#pool.query(\n `INSERT INTO ${TABLE} (prefix, collection, key, doc, expires_at) VALUES ($1, $2, $3, $4::jsonb, $5)\n ON CONFLICT (prefix, collection, key) DO UPDATE SET doc = EXCLUDED.doc, expires_at = EXCLUDED.expires_at`,\n [this.#prefix, collection, key, doc, expiresAt],\n )\n return\n }\n const conditionKey = options.conditionKey\n const client = await this.#pool.connect()\n try {\n await client.query('BEGIN')\n const { rows } = await client.query<{ doc: Record<string, unknown> }>(\n `SELECT doc FROM ${TABLE} WHERE prefix = $1 AND collection = $2 AND key = $3 AND ${this.#live(4)}\n FOR UPDATE`,\n [this.#prefix, collection, key, this.#nowDate()],\n )\n const current = rows[0]?.doc\n // `===`, as the reference store compares: an object or array never matches.\n const holds = current !== undefined && current[conditionKey] === options.conditionValue\n if (!holds) {\n await client.query('ROLLBACK')\n throw new AdapterError(\n 'condition-failed',\n current === undefined\n ? `Condition failed on ${collection}/${key}: document does not exist`\n : `Condition failed on ${collection}/${key}: ${conditionKey} mismatch`,\n { context: { collection, key, conditionKey } },\n )\n }\n await client.query(\n `UPDATE ${TABLE} SET doc = $4::jsonb, expires_at = $5 WHERE prefix = $1 AND collection = $2 AND key = $3`,\n [this.#prefix, collection, key, doc, expiresAt],\n )\n await client.query('COMMIT')\n } catch (error) {\n if (!(error instanceof AdapterError)) await client.query('ROLLBACK').catch(() => undefined)\n throw error\n } finally {\n client.release()\n }\n }\n\n async delete(collection: string, key: string): Promise<void> {\n await this.#pool.query(\n `DELETE FROM ${TABLE} WHERE prefix = $1 AND collection = $2 AND key = $3`,\n [this.#prefix, collection, key],\n )\n }\n\n async query<T>(collection: string, params: QueryParams): Promise<QueryResult<T>> {\n const { field, value } = params.partitionKey\n const narrow = value !== '*' && narrowable(value)\n const { rows } = await this.#pool.query<{ key: string; doc: unknown }>(\n `SELECT key, doc FROM ${TABLE} WHERE prefix = $1 AND collection = $2 AND ${this.#live(3)}` +\n (narrow ? ' AND doc @> $4::jsonb' : ''),\n [\n this.#prefix,\n collection,\n this.#nowDate(),\n ...(narrow ? [JSON.stringify({ [field]: value })] : []),\n ],\n )\n return queryDocuments<T>(\n rows.map((r) => [r.key, r.doc] as const),\n params,\n )\n }\n\n async batchGet<T>(collection: string, keys: string[]): Promise<Map<string, T>> {\n const result = new Map<string, T>()\n if (keys.length === 0) return result\n const { rows } = await this.#pool.query<{ key: string; doc: T }>(\n `SELECT key, doc FROM ${TABLE} WHERE prefix = $1 AND collection = $2 AND key = ANY($3::text[]) AND ${this.#live(4)}`,\n [this.#prefix, collection, keys, this.#nowDate()],\n )\n const byKey = new Map(rows.map((r) => [r.key, r.doc]))\n for (const key of keys) {\n const doc = byKey.get(key)\n if (doc !== undefined) result.set(key, doc)\n }\n return result\n }\n\n async batchPut<T>(collection: string, items: Map<string, T>): Promise<void> {\n const entries = [...items]\n for (let i = 0; i < entries.length; i += BATCH_ROWS) {\n const chunk = entries.slice(i, i + BATCH_ROWS)\n await this.#pool.query(\n `INSERT INTO ${TABLE} (prefix, collection, key, doc, expires_at)\n SELECT $1, $2, k, d, NULL FROM unnest($3::text[], $4::jsonb[]) AS t(k, d)\n ON CONFLICT (prefix, collection, key) DO UPDATE SET doc = EXCLUDED.doc, expires_at = NULL`,\n [\n this.#prefix,\n collection,\n chunk.map(([key]) => key),\n chunk.map(([, doc]) => JSON.stringify(doc)),\n ],\n )\n }\n }\n\n /**\n * One transaction: the collection's rows are deleted and the new ones inserted, so a reader (on another connection)\n * sees the old rows until the commit and the new ones after it. A source that throws rolls it back.\n */\n async replaceCollection<T>(\n collection: string,\n entries: AsyncIterable<[string, T]>,\n ): Promise<void> {\n const client = await this.#pool.connect()\n try {\n await client.query('BEGIN')\n await client.query(`DELETE FROM ${TABLE} WHERE prefix = $1 AND collection = $2`, [\n this.#prefix,\n collection,\n ])\n let chunk = new Map<string, string>()\n const flush = async () => {\n if (chunk.size === 0) return\n await client.query(\n `INSERT INTO ${TABLE} (prefix, collection, key, doc, expires_at)\n SELECT $1, $2, k, d, NULL FROM unnest($3::text[], $4::jsonb[]) AS t(k, d)\n ON CONFLICT (prefix, collection, key) DO UPDATE SET doc = EXCLUDED.doc, expires_at = NULL`,\n [this.#prefix, collection, [...chunk.keys()], [...chunk.values()]],\n )\n chunk = new Map()\n }\n for await (const [key, doc] of entries) {\n chunk.set(key, JSON.stringify(doc))\n if (chunk.size >= BATCH_ROWS) await flush()\n }\n await flush()\n await client.query('COMMIT')\n } catch (error) {\n await client.query('ROLLBACK').catch(() => undefined)\n throw error\n } finally {\n client.release()\n }\n }\n\n /** Deletes this prefix's expired rows. Reads never return them in the meantime. */\n async sweep(): Promise<void> {\n try {\n await this.#pool.query(`DELETE FROM ${TABLE} WHERE prefix = $1 AND expires_at <= $2`, [\n this.#prefix,\n this.#nowDate(),\n ])\n } catch (err) {\n this.#logger?.warn({ err }, 'postgres jsonb document store: expired rows were not swept')\n }\n }\n\n async close(): Promise<void> {\n if (this.#sweepTimer !== null) clearInterval(this.#sweepTimer)\n this.#sweepTimer = null\n await this.#pool.end()\n }\n\n /** The not-expired condition, with \"now\" as parameter `$n`: the store's clock, not the server's. */\n #live(n: number): string {\n return LIVE.replace('$NOW', `$${n}`)\n }\n\n #nowDate(): Date {\n return new Date(this.#now())\n }\n}\n\nexport function createPostgresJsonbDocumentStore(\n options: PostgresJsonbDocumentStoreOptions,\n): PostgresJsonbDocumentStore {\n return new PostgresJsonbDocumentStore(options)\n}\n"],"mappings":";AAAA,SAAS,mBAAwC;;;ACAjD,OAAO,QAAQ;AACf;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAOK;AAaP,IAAM,QAAQ;AAEd,IAAM,WAAW;AAEjB,IAAM,MAAM;AAAA,EACV,8BAA8B,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASnC,8BAA8B,KAAK,eAAe,KAAK;AAAA,EACvD,8BAA8B,KAAK,mBAAmB,KAAK;AAC7D;AAEA,IAAM,OAAO;AACb,IAAM,aAAa;AAGnB,SAAS,WAAW,OAAyB;AAC3C,SAAO,OAAO,UAAU,YAAY,OAAO,UAAU,YAAY,OAAO,UAAU;AACpF;AAMO,IAAM,6BAAN,MAAiE;AAAA,EAC7D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACT,cAAqC;AAAA,EAErC,YAAY,SAA4C;AACtD,SAAK,QAAQ,IAAI,GAAG,KAAK,EAAE,kBAAkB,QAAQ,aAAa,KAAK,EAAE,CAAC;AAC1E,SAAK,MAAM,GAAG,SAAS,CAAC,QAAQ,QAAQ,QAAQ,KAAK,EAAE,IAAI,GAAG,2BAA2B,CAAC;AAC1F,SAAK,OAAO,QAAQ;AACpB,SAAK,UAAU,QAAQ;AACvB,SAAK,OAAO,QAAQ,OAAO,KAAK;AAChC,SAAK,UAAU,QAAQ;AACvB,SAAK,mBAAmB,QAAQ,mBAAmB;AAAA,EACrD;AAAA,EAEA,eAAkC;AAChC,WAAO,CAAC,OAAO,OAAO,UAAU,SAAS,SAAS,OAAO,iBAAiB;AAAA,EAC5E;AAAA;AAAA,EAGA,MAAM,aAA4B;AAChC,UAAM,SAAS,MAAM,KAAK,MAAM,QAAQ;AACxC,QAAI;AACF,YAAM,OAAO,MAAM,OAAO;AAC1B,YAAM,OAAO,MAAM,oCAAoC,CAAC,QAAQ,CAAC;AACjE,iBAAW,aAAa,IAAK,OAAM,OAAO,MAAM,SAAS;AACzD,YAAM,OAAO,MAAM,QAAQ;AAAA,IAC7B,SAAS,OAAO;AACd,YAAM,OAAO,MAAM,UAAU,EAAE,MAAM,MAAM,MAAS;AACpD,YAAM;AAAA,IACR,UAAE;AACA,aAAO,QAAQ;AAAA,IACjB;AACA,QAAI,KAAK,gBAAgB,QAAQ,KAAK,mBAAmB,GAAG;AAC1D,WAAK,cAAc,YAAY,MAAM,KAAK,KAAK,MAAM,GAAG,KAAK,gBAAgB;AAC7E,WAAK,YAAY,MAAM;AAAA,IACzB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,cAAqC;AACnC,UAAM,SAAS,IAAI,GAAG,OAAO;AAAA,MAC3B,kBAAkB,KAAK;AAAA,MACvB,yBAAyB;AAAA,IAC3B,CAAC;AAED,WAAO,GAAG,SAAS,MAAM,MAAS;AAClC,WAAO;AAAA,MACL,YAAY;AACV,cAAM,OAAO,QAAQ;AACrB,cAAM,EAAE,KAAK,IAAI,MAAM,OAAO;AAAA,UAC5B,uBAAuB,KAAK;AAAA,QAC9B;AACA,eAAO,KAAK,CAAC,GAAG,SAAS,OACrB,EAAE,QAAQ,YAAY,QAAQ,6CAA6C,IAC3E,EAAE,QAAQ,UAAU;AAAA,MAC1B;AAAA,MACA,EAAE,QAAQ,kBAAkB,KAAK,IAAI,EAAE;AAAA,IACzC,EAAE;AAAA,MAAQ;AAAA;AAAA,QAER,OAAO,IAAI,EAAE,MAAM,MAAM,MAAS;AAAA;AAAA,IACpC;AAAA,EACF;AAAA,EAEA,MAAM,IAAO,YAAoB,KAAgC;AAC/D,UAAM,EAAE,KAAK,IAAI,MAAM,KAAK,MAAM;AAAA,MAChC,mBAAmB,KAAK,2DAA2D,KAAK,MAAM,CAAC,CAAC;AAAA,MAChG,CAAC,KAAK,SAAS,YAAY,KAAK,KAAK,SAAS,CAAC;AAAA,IACjD;AACA,WAAO,KAAK,CAAC,GAAG,OAAO;AAAA,EACzB;AAAA,EAEA,MAAM,IACJ,YACA,KACA,UACA,UAAsB,CAAC,GACR;AACf,UAAM,YAAY,QAAQ,QAAQ,SAAY,IAAI,KAAK,KAAK,KAAK,IAAI,QAAQ,MAAM,GAAI,IAAI;AAC3F,UAAM,MAAM,KAAK,UAAU,QAAQ;AACnC,QAAI,QAAQ,iBAAiB,QAAW;AACtC,YAAM,KAAK,MAAM;AAAA,QACf,eAAe,KAAK;AAAA;AAAA,QAEpB,CAAC,KAAK,SAAS,YAAY,KAAK,KAAK,SAAS;AAAA,MAChD;AACA;AAAA,IACF;AACA,UAAM,eAAe,QAAQ;AAC7B,UAAM,SAAS,MAAM,KAAK,MAAM,QAAQ;AACxC,QAAI;AACF,YAAM,OAAO,MAAM,OAAO;AAC1B,YAAM,EAAE,KAAK,IAAI,MAAM,OAAO;AAAA,QAC5B,mBAAmB,KAAK,2DAA2D,KAAK,MAAM,CAAC,CAAC;AAAA;AAAA,QAEhG,CAAC,KAAK,SAAS,YAAY,KAAK,KAAK,SAAS,CAAC;AAAA,MACjD;AACA,YAAM,UAAU,KAAK,CAAC,GAAG;AAEzB,YAAM,QAAQ,YAAY,UAAa,QAAQ,YAAY,MAAM,QAAQ;AACzE,UAAI,CAAC,OAAO;AACV,cAAM,OAAO,MAAM,UAAU;AAC7B,cAAM,IAAI;AAAA,UACR;AAAA,UACA,YAAY,SACR,uBAAuB,UAAU,IAAI,GAAG,8BACxC,uBAAuB,UAAU,IAAI,GAAG,KAAK,YAAY;AAAA,UAC7D,EAAE,SAAS,EAAE,YAAY,KAAK,aAAa,EAAE;AAAA,QAC/C;AAAA,MACF;AACA,YAAM,OAAO;AAAA,QACX,UAAU,KAAK;AAAA,QACf,CAAC,KAAK,SAAS,YAAY,KAAK,KAAK,SAAS;AAAA,MAChD;AACA,YAAM,OAAO,MAAM,QAAQ;AAAA,IAC7B,SAAS,OAAO;AACd,UAAI,EAAE,iBAAiB,cAAe,OAAM,OAAO,MAAM,UAAU,EAAE,MAAM,MAAM,MAAS;AAC1F,YAAM;AAAA,IACR,UAAE;AACA,aAAO,QAAQ;AAAA,IACjB;AAAA,EACF;AAAA,EAEA,MAAM,OAAO,YAAoB,KAA4B;AAC3D,UAAM,KAAK,MAAM;AAAA,MACf,eAAe,KAAK;AAAA,MACpB,CAAC,KAAK,SAAS,YAAY,GAAG;AAAA,IAChC;AAAA,EACF;AAAA,EAEA,MAAM,MAAS,YAAoB,QAA8C;AAC/E,UAAM,EAAE,OAAO,MAAM,IAAI,OAAO;AAChC,UAAM,SAAS,UAAU,OAAO,WAAW,KAAK;AAChD,UAAM,EAAE,KAAK,IAAI,MAAM,KAAK,MAAM;AAAA,MAChC,wBAAwB,KAAK,8CAA8C,KAAK,MAAM,CAAC,CAAC,MACrF,SAAS,0BAA0B;AAAA,MACtC;AAAA,QACE,KAAK;AAAA,QACL;AAAA,QACA,KAAK,SAAS;AAAA,QACd,GAAI,SAAS,CAAC,KAAK,UAAU,EAAE,CAAC,KAAK,GAAG,MAAM,CAAC,CAAC,IAAI,CAAC;AAAA,MACvD;AAAA,IACF;AACA,WAAO;AAAA,MACL,KAAK,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,GAAG,CAAU;AAAA,MACvC;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,SAAY,YAAoB,MAAyC;AAC7E,UAAM,SAAS,oBAAI,IAAe;AAClC,QAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,UAAM,EAAE,KAAK,IAAI,MAAM,KAAK,MAAM;AAAA,MAChC,wBAAwB,KAAK,wEAAwE,KAAK,MAAM,CAAC,CAAC;AAAA,MAClH,CAAC,KAAK,SAAS,YAAY,MAAM,KAAK,SAAS,CAAC;AAAA,IAClD;AACA,UAAM,QAAQ,IAAI,IAAI,KAAK,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,CAAC;AACrD,eAAW,OAAO,MAAM;AACtB,YAAM,MAAM,MAAM,IAAI,GAAG;AACzB,UAAI,QAAQ,OAAW,QAAO,IAAI,KAAK,GAAG;AAAA,IAC5C;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,SAAY,YAAoB,OAAsC;AAC1E,UAAM,UAAU,CAAC,GAAG,KAAK;AACzB,aAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK,YAAY;AACnD,YAAM,QAAQ,QAAQ,MAAM,GAAG,IAAI,UAAU;AAC7C,YAAM,KAAK,MAAM;AAAA,QACf,eAAe,KAAK;AAAA;AAAA;AAAA,QAGpB;AAAA,UACE,KAAK;AAAA,UACL;AAAA,UACA,MAAM,IAAI,CAAC,CAAC,GAAG,MAAM,GAAG;AAAA,UACxB,MAAM,IAAI,CAAC,CAAC,EAAE,GAAG,MAAM,KAAK,UAAU,GAAG,CAAC;AAAA,QAC5C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,kBACJ,YACA,SACe;AACf,UAAM,SAAS,MAAM,KAAK,MAAM,QAAQ;AACxC,QAAI;AACF,YAAM,OAAO,MAAM,OAAO;AAC1B,YAAM,OAAO,MAAM,eAAe,KAAK,0CAA0C;AAAA,QAC/E,KAAK;AAAA,QACL;AAAA,MACF,CAAC;AACD,UAAI,QAAQ,oBAAI,IAAoB;AACpC,YAAM,QAAQ,YAAY;AACxB,YAAI,MAAM,SAAS,EAAG;AACtB,cAAM,OAAO;AAAA,UACX,eAAe,KAAK;AAAA;AAAA;AAAA,UAGpB,CAAC,KAAK,SAAS,YAAY,CAAC,GAAG,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,MAAM,OAAO,CAAC,CAAC;AAAA,QACnE;AACA,gBAAQ,oBAAI,IAAI;AAAA,MAClB;AACA,uBAAiB,CAAC,KAAK,GAAG,KAAK,SAAS;AACtC,cAAM,IAAI,KAAK,KAAK,UAAU,GAAG,CAAC;AAClC,YAAI,MAAM,QAAQ,WAAY,OAAM,MAAM;AAAA,MAC5C;AACA,YAAM,MAAM;AACZ,YAAM,OAAO,MAAM,QAAQ;AAAA,IAC7B,SAAS,OAAO;AACd,YAAM,OAAO,MAAM,UAAU,EAAE,MAAM,MAAM,MAAS;AACpD,YAAM;AAAA,IACR,UAAE;AACA,aAAO,QAAQ;AAAA,IACjB;AAAA,EACF;AAAA;AAAA,EAGA,MAAM,QAAuB;AAC3B,QAAI;AACF,YAAM,KAAK,MAAM,MAAM,eAAe,KAAK,2CAA2C;AAAA,QACpF,KAAK;AAAA,QACL,KAAK,SAAS;AAAA,MAChB,CAAC;AAAA,IACH,SAAS,KAAK;AACZ,WAAK,SAAS,KAAK,EAAE,IAAI,GAAG,4DAA4D;AAAA,IAC1F;AAAA,EACF;AAAA,EAEA,MAAM,QAAuB;AAC3B,QAAI,KAAK,gBAAgB,KAAM,eAAc,KAAK,WAAW;AAC7D,SAAK,cAAc;AACnB,UAAM,KAAK,MAAM,IAAI;AAAA,EACvB;AAAA;AAAA,EAGA,MAAM,GAAmB;AACvB,WAAO,KAAK,QAAQ,QAAQ,IAAI,CAAC,EAAE;AAAA,EACrC;AAAA,EAEA,WAAiB;AACf,WAAO,IAAI,KAAK,KAAK,KAAK,CAAC;AAAA,EAC7B;AACF;AAEO,SAAS,iCACd,SAC4B;AAC5B,SAAO,IAAI,2BAA2B,OAAO;AAC/C;;;AD5SO,SAAS,cAAc,KAAiD;AAC7E,MAAI,IAAI,SAAS,iBAAiB;AAChC,UAAM,IAAI;AAAA,MACR,gEAAgE,IAAI,IAAI;AAAA,MACxE,EAAE,MAAM,sBAAsB,SAAS,EAAE,MAAM,IAAI,KAAK,EAAE;AAAA,IAC5D;AAAA,EACF;AACA,QAAM,aAAa,IAAI,OAAO,aAAa;AAC3C,QAAM,cACJ,OAAO,eAAe,YAAY,eAAe,KAAK,aAAa,IAAI,IAAI,cAAc;AAC3F,QAAM,SAAS,IAAI,OAAO,QAAQ;AAClC,MAAI,gBAAgB,UAAa,gBAAgB,IAAI;AACnD,UAAM,IAAI;AAAA,MACR;AAAA,MACA,EAAE,MAAM,+BAA+B,SAAS,EAAE,MAAM,IAAI,KAAK,EAAE;AAAA,IACrE;AAAA,EACF;AACA,MAAI,OAAO,WAAW,YAAY,WAAW,IAAI;AAC/C,UAAM,IAAI,YAAY,qDAAqD;AAAA,MACzE,MAAM;AAAA,MACN,SAAS,EAAE,MAAM,IAAI,KAAK;AAAA,IAC5B,CAAC;AAAA,EACH;AACA,SAAO,iCAAiC,EAAE,aAAa,QAAQ,QAAQ,IAAI,OAAO,CAAC;AACrF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@nymbalhq/doc-store-postgres-jsonb",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.2.0",
4
+ "description": "The Postgres JSONB document store for Nymbal: `infrastructure.documentStore: 'postgres-jsonb'`",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/nymbalhq/nymbal.git",
9
+ "directory": "packages/doc-store-postgres-jsonb"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "type": "module",
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "dependencies": {
30
+ "@nymbalhq/types": "1.2.0"
31
+ },
32
+ "peerDependencies": {
33
+ "pg": "^8.13.1"
34
+ },
35
+ "devDependencies": {
36
+ "@nymbalhq/contract-tests": "1.2.0",
37
+ "@types/pg": "^8.11.10",
38
+ "@typescript/native": "npm:typescript@^7.0.2",
39
+ "fast-check": "^4.10.2",
40
+ "pg": "^8.13.1",
41
+ "tsup": "^9.0.0",
42
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
43
+ "vitest": "^3.0.0"
44
+ },
45
+ "scripts": {
46
+ "build": "tsup",
47
+ "dev": "tsup --watch",
48
+ "typecheck": "tsc -p tsconfig.json --noEmit --rootDir ../.."
49
+ }
6
50
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
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.