@protobase/server 0.55.2 → 0.55.4

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.
@@ -1,7 +1,7 @@
1
1
  import { notFound } from "../problem.js";
2
2
  import { aggregate } from "../aggregate-groups.js";
3
- import { runWrite } from "../write-pipeline.js";
4
3
  import { decodeKeyText } from "../key.js";
4
+ import { runWrite } from "../write-pipeline.js";
5
5
  import { runBatch } from "../batch/run-batch.js";
6
6
  import { listRecords } from "../list-service.js";
7
7
  import { readRecord } from "../read-service.js";
@@ -26,10 +26,12 @@ const queryProblem = (error) => {
26
26
  return new HttpProblem(status, error.code.replaceAll("_", "-"), titles[status] ?? "Not Implemented", error.message);
27
27
  };
28
28
  const isDatabaseError = (error) => error instanceof Error && /^[0-9A-Z]{5}$/.test(String(error.code));
29
+ /** The 409 of a unique violation, with the key of the record holding the values (`existing.key`) when the caller may read it. */
30
+ const takenProblem = (key) => new HttpProblem(409, "conflict", "Conflict", "A record with the same unique value already exists", key === void 0 ? {} : { existing: { key } });
29
31
  const databaseProblem = (error) => {
30
32
  if (error.code === "25006") return new HttpProblem(503, "read-only", "Service Unavailable", "The app is read-only while it is being updated", {}, { "Retry-After": "5" });
31
33
  if (error.code === "57014") return new HttpProblem(504, "statement-timeout", "Gateway Timeout", "A query ran longer than its statement timeout, so the database stopped it");
32
- if (error.code === "23505") return new HttpProblem(409, "conflict", "Conflict", "A record with the same unique value already exists");
34
+ if (error.code === "23505") return takenProblem();
33
35
  if (error.code === "23503") return new HttpProblem(409, "conflict", "Conflict", "The record points at, or is pointed at by, another record");
34
36
  if (error.code === "40P01") return new HttpProblem(409, "conflict", "Conflict", "The record was written at the same time as another; send it again");
35
37
  if (error.code === "23502") return new HttpProblem(400, "invalid-record", "Bad Request", "A required column has no value");
@@ -65,4 +67,4 @@ const createErrorHandler = (onUnhandledError, statementTimeoutMs) => (error, c)
65
67
  return problemResponse(new HttpProblem(500, "internal-error", titles[500], "The server failed to handle the request"), c.req.path);
66
68
  };
67
69
  //#endregion
68
- export { createErrorHandler, isDatabaseError, problemOf, queryProblem };
70
+ export { createErrorHandler, isDatabaseError, problemOf, queryProblem, takenProblem };
@@ -0,0 +1,31 @@
1
+ import { isDatabaseError, takenProblem } from "./error-handler.js";
2
+ import { insertRecord, insertUnlessTaken, takenKeySetting } from "./records.js";
3
+ import { fetchReadable } from "./fetch-readable.js";
4
+ import { firstKeyField } from "./write-shared.js";
5
+ import { decodeKeyText } from "./key.js";
6
+ import { encodeKey } from "@protobase/schema/internal";
7
+ import { sql } from "kysely";
8
+ //#region src/open-existing.ts
9
+ const readableTakenKey = async (trx, access) => {
10
+ if (!access.resolved.operations.read) return;
11
+ const { rows } = await sql`select current_setting(${takenKeySetting}) as key`.execute(trx);
12
+ const key = decodeKeyText(access.entry, encodeKey(JSON.parse(rows[0].key)));
13
+ return await fetchReadable(trx, access, key, "hide", { fields: [firstKeyField(access)] }) ? encodeKey(key) : void 0;
14
+ };
15
+ /**
16
+ * Inserts a create's row. On a resource with `.openExisting()`, a row whose values of that unique key a record already
17
+ * holds is refused with the 409 any unique violation answers, plus that record's key when the caller may read it. A
18
+ * row with no values given at all is inserted plainly, as the conflict needs a column to name.
19
+ */
20
+ const insertOrOpen = async (trx, access, values, shape) => {
21
+ const unique = access.full.openExisting;
22
+ if (!unique || Object.keys(values).length === 0) return insertRecord(trx, access.entry, values, shape);
23
+ const inserted = await insertUnlessTaken(trx, access.entry, values, unique, shape).catch((error) => {
24
+ if (isDatabaseError(error) && error.code === "42P10") throw new Error(`The openExisting fields of ${access.entry.name} (${unique.join(", ")}) are not the columns of a unique index of its table`);
25
+ throw error;
26
+ });
27
+ if (inserted) return inserted;
28
+ throw takenProblem(await readableTakenKey(trx, access));
29
+ };
30
+ //#endregion
31
+ export { insertOrOpen };
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.55.2";
2
+ var version = "0.55.4";
3
3
  //#endregion
4
4
  export { version };
package/dist/records.js CHANGED
@@ -30,12 +30,34 @@ const fetchRecord = async (db, entry, key, tenant, options = {}) => {
30
30
  const query = sql`select ${returningList(entry, shape.fields)} from ${tableId(entry.model)} as t${whereClause(conditions)} limit 1${lockClause(lock)}`;
31
31
  return stored(entry, (await rowsOf(db, query))[0], shape);
32
32
  };
33
- const insertRecord = async (db, entry, values, shape = {}) => {
33
+ const insertBody = (entry, values) => {
34
34
  const fields = Object.keys(values).map((name) => entry.model.fields[name]);
35
- const body = fields.length === 0 ? sql`default values` : sql`(${sql.join(fields.map(columnId))}) values (${sql.join(fields.map((field) => storedValue(field, values[field.name])))})`;
36
- const query = sql`insert into ${tableId(entry.model)} ${body} returning ${returningList(entry, shape.fields)}`;
35
+ return fields.length === 0 ? sql`default values` : sql`(${sql.join(fields.map(columnId))}) values (${sql.join(fields.map((field) => storedValue(field, values[field.name])))})`;
36
+ };
37
+ const insertRecord = async (db, entry, values, shape = {}) => {
38
+ const query = sql`insert into ${tableId(entry.model)} ${insertBody(entry, values)} returning ${returningList(entry, shape.fields)}`;
37
39
  return await stored(entry, (await rowsOf(db, query))[0], shape);
38
40
  };
41
+ /** The setting `insertUnlessTaken` leaves the taken row's key in, as a JSON array of text, for the rest of the transaction. */
42
+ const takenKeySetting = "protobase.taken_key";
43
+ /**
44
+ * Inserts the row unless another row already holds its values of the unique key `unique` (field names), and then gives
45
+ * `undefined` and leaves that row's key in `takenKeySetting`, its parts as text. The conflict's `do update` never
46
+ * updates: its condition only records the key and is false, so no update trigger fires; Postgres still locks the taken
47
+ * row until the transaction ends, and asks for the update privilege. A violation of any other unique key raises 23505
48
+ * as a plain insert does. `values` must name at least one field, the column the never-run update would set.
49
+ */
50
+ const insertUnlessTaken = async (db, entry, values, unique, shape = {}) => {
51
+ const { model } = entry;
52
+ const first = Object.keys(values)[0];
53
+ if (first === void 0) throw new Error(`A ${entry.name} record with no values given cannot open an existing one`);
54
+ const set = columnId(model.fields[first]);
55
+ const target = sql.join(unique.map((name) => columnId(model.fields[name])));
56
+ const key = sql.join(model.primaryKey.map((name) => sql`t.${columnId(model.fields[name])}::text`));
57
+ const taken = sql`set_config(${takenKeySetting}, json_build_array(${key})::text, true)`;
58
+ const query = sql`insert into ${tableId(model)} as t ${insertBody(entry, values)} on conflict (${target}) do update set ${set} = t.${set} where ${taken} is null returning ${returningList(entry, shape.fields)}`;
59
+ return stored(entry, (await rowsOf(db, query))[0], shape);
60
+ };
39
61
  const etagBump = (entry) => {
40
62
  if (!entry.etag) return [];
41
63
  const column = columnId(entry.etag.field);
@@ -69,4 +91,4 @@ const restoreRecord = async (db, entry, key, tenant, shape = {}) => {
69
91
  return await stored(entry, (await rowsOf(db, query))[0], shape);
70
92
  };
71
93
  //#endregion
72
- export { deleteRecord, fetchRecord, insertRecord, restoreRecord, updateRecord };
94
+ export { deleteRecord, fetchRecord, insertRecord, insertUnlessTaken, restoreRecord, takenKeySetting, updateRecord };
@@ -1,8 +1,8 @@
1
1
  import { notFound } from "../problem.js";
2
2
  import { pickFields } from "../rows.js";
3
3
  import { selectedFields } from "../params.js";
4
- import { runWrite } from "../write-pipeline.js";
5
4
  import { keyFromPath } from "../key.js";
5
+ import { runWrite } from "../write-pipeline.js";
6
6
  import { readRecord } from "../read-service.js";
7
7
  import { postComment } from "../comments/post-comment.js";
8
8
  import { readComments } from "../comments/read-comments.js";
@@ -309,6 +309,7 @@ const viewShape = (resource, model, models, prompted, createPages, writeOnly) =>
309
309
  group: shapes.data,
310
310
  order: shapes.data,
311
311
  tenant: shapes.data,
312
+ create: shapes.data,
312
313
  recent: shapes.map((recent) => navRecentErrors(recent, model).length === 0 ? recent : void 0)
313
314
  });
314
315
  const chart = shapes.map((chart) => chartFields(chart).every((name) => Object.hasOwn(model.fields, name)) ? chart : void 0);
@@ -6,9 +6,10 @@ import { actorSettings, wakeDispatch } from "./changes/actor.js";
6
6
  import { isDatabaseError } from "./error-handler.js";
7
7
  import { attachFiles, scheduleRemovedFiles } from "./files/attach.js";
8
8
  import { bindingOf, pendingBinding } from "./encryption/binding.js";
9
- import { deleteRecord, fetchRecord, insertRecord, restoreRecord, updateRecord } from "./records.js";
9
+ import { deleteRecord, fetchRecord, restoreRecord, updateRecord } from "./records.js";
10
10
  import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown } from "./write-shared.js";
11
11
  import { blockedDelete } from "./blocked-delete.js";
12
+ import { insertOrOpen } from "./open-existing.js";
12
13
  import { encryptedValuesOf, hasEncryptedValues, sealValues } from "./encryption/seal-values.js";
13
14
  import { validateBody } from "./validate-body.js";
14
15
  import { applyUpdate, planUpdate } from "./write-update.js";
@@ -45,7 +46,7 @@ const create = async (trx, deps, access, body, removed) => {
45
46
  const parents = checkParents(trx, access, "create");
46
47
  await parents(row);
47
48
  const shape = { etagFields: Object.keys(access.model.fields) };
48
- const inserted = await insertRecord(trx, entry, sealValues(deps.encryption, full, row, (field) => pendingBinding(full, field)), shape);
49
+ const inserted = await insertOrOpen(trx, access, sealValues(deps.encryption, full, row, (field) => pendingBinding(full, field)), shape);
49
50
  const created = await bindEncrypted(trx, deps, access, row, inserted, shape);
50
51
  await assertWritten(trx, access, created, "create", access.resolved.rowFilter.create, filter);
51
52
  await parents(created.record);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.55.2",
3
+ "version": "0.55.4",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -40,9 +40,9 @@
40
40
  "@better-auth/passkey": "^1.7.12",
41
41
  "@better-auth/sso": "^1.7.12",
42
42
  "@modelcontextprotocol/server": "^2.3.1",
43
- "@protobase/layout": "0.55.2",
44
- "@protobase/query": "0.55.2",
45
- "@protobase/schema": "0.55.2",
43
+ "@protobase/layout": "0.55.4",
44
+ "@protobase/query": "0.55.4",
45
+ "@protobase/schema": "0.55.4",
46
46
  "@react-email/render": "^2.1.0",
47
47
  "@scalar/hono-api-reference": "^0.12.10",
48
48
  "@sentry/core": "^11.7.0",