@rebasepro/server-postgres 0.19.2-canary.gef769df → 0.20.1-canary.g4d882ca

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/dist/index.es.js CHANGED
@@ -2537,12 +2537,49 @@ function coerceDeclaredNumber(value, property) {
2537
2537
  const parsed = parseFloat(value);
2538
2538
  return isNaN(parsed) ? null : parsed;
2539
2539
  }
2540
- /** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
2541
- function coerceDeclaredNumbers(row, collection) {
2540
+ /**
2541
+ * Serve a `date` column as the timestamp this API says it serves.
2542
+ *
2543
+ * The OpenAPI document this server publishes types a `date` property as
2544
+ * `string, format: date-time` — RFC 3339 — and node-postgres hands over the
2545
+ * Postgres literal: `"2026-08-24 01:37:57.647+02"`, with a space where the `T`
2546
+ * belongs and a two-digit offset. V8 parses that by accident; Safari's
2547
+ * `new Date()` does not have to, and the spec never promised it. A column whose
2548
+ * documented type only parses in some browsers is not a contract.
2549
+ *
2550
+ * A date-only column (`mode: "date"`, `format: "date"` in the spec) is already
2551
+ * RFC 3339 as `YYYY-MM-DD` and is left alone — widening it to a timestamp would
2552
+ * invent a time of day and a timezone the column does not have.
2553
+ */
2554
+ function toRestDate(value) {
2555
+ if (value instanceof Date) return isNaN(value.getTime()) ? null : value.toISOString();
2556
+ if (value && typeof value === "object" && value.__type === "date") return value.value ?? null;
2557
+ if (typeof value !== "string" && typeof value !== "number") return value;
2558
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
2559
+ const date = new Date(value);
2560
+ return isNaN(date.getTime()) ? value : date.toISOString();
2561
+ }
2562
+ /**
2563
+ * One scalar, as REST serves it: declared numbers as numbers, declared dates as
2564
+ * RFC 3339, everything else exactly as the database returned it.
2565
+ */
2566
+ function toRestScalar(value, property) {
2567
+ if (property?.type === "date") return toRestDate(value);
2568
+ return coerceDeclaredNumber(value, property);
2569
+ }
2570
+ /**
2571
+ * Apply {@link toRestScalar} across a row, leaving undeclared columns alone.
2572
+ *
2573
+ * Exported because the include loader attaches related rows itself, and a
2574
+ * target rendered differently from its parent is the shape bug this whole file
2575
+ * exists to prevent — a date was a string at the top level and a
2576
+ * `{ __type: "date" }` envelope one level down, in the same response.
2577
+ */
2578
+ function toRestValues(row, collection) {
2542
2579
  const properties = collection.properties;
2543
2580
  if (!properties) return row;
2544
2581
  const out = {};
2545
- for (const [key, value] of Object.entries(row)) out[key] = coerceDeclaredNumber(value, properties[key]);
2582
+ for (const [key, value] of Object.entries(row)) out[key] = toRestScalar(value, properties[key]);
2546
2583
  return out;
2547
2584
  }
2548
2585
  /** Render one target row in the requested style. */
@@ -2588,7 +2625,7 @@ function stripUnreadable(row, collection) {
2588
2625
  return row;
2589
2626
  }
2590
2627
  function renderTarget(targetRow, targetCollection, style, registry) {
2591
- if (style === "inline") return stripUnreadable(coerceDeclaredNumbers({ ...targetRow }, targetCollection), targetCollection);
2628
+ if (style === "inline") return stripUnreadable(toRestValues({ ...targetRow }, targetCollection), targetCollection);
2592
2629
  const address = relationTargetAddress(targetRow, targetCollection, registry);
2593
2630
  const path = targetCollection.slug;
2594
2631
  return createRelationRefWithData(address, path, {
@@ -2638,9 +2675,9 @@ function toFlatRow(row, collection, registry) {
2638
2675
  *
2639
2676
  * Values are the ones the database returned, except where that contradicts the
2640
2677
  * declared type: a `number` property is served as a number (see
2641
- * {@link coerceDeclaredNumber}). Dates stay as the database returned them
2642
- * JSON has its own opinions about dates that the admin's view-model does not
2643
- * share.
2678
+ * {@link coerceDeclaredNumber}) and a `date` as RFC 3339, which is what this
2679
+ * server's own OpenAPI document says a date column is (see
2680
+ * {@link toRestDate}).
2644
2681
  *
2645
2682
  * Keyed by the row rather than by the relation list — a REST fetch only loads
2646
2683
  * the relations `include` asked for, so the row is the authority on which are
@@ -2653,7 +2690,7 @@ function toRestRow(row, collection, registry) {
2653
2690
  const relation = findRelation(resolvedRelations, key);
2654
2691
  if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
2655
2692
  else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
2656
- else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
2693
+ else flat[key] = toRestScalar(value, collection.properties?.[key]);
2657
2694
  }
2658
2695
  return stripUnreadable(flat, collection);
2659
2696
  }
@@ -3515,6 +3552,28 @@ function compareForSort(a, b, nullsLast) {
3515
3552
  * Service for handling all row read operations.
3516
3553
  * Handles fetching, searching, counting, and filtering rows.
3517
3554
  */
3555
+ /**
3556
+ * Which aggregate aliases hold a number that Postgres handed back as a string.
3557
+ *
3558
+ * `count`, `sum` and `avg` always do: bigint and numeric are returned as text
3559
+ * because they do not fit a JS number in general. `min` and `max` are uncast,
3560
+ * so they do it too — but only over a numeric column, and those are the two
3561
+ * functions that also apply to text and dates.
3562
+ *
3563
+ * Decided by the column's DECLARED type, not by trying `Number()` on the value.
3564
+ * `?select=avg(price),max(price)` used to answer `{avg_price: 5,
3565
+ * max_price: "8.5"}` — one column, two functions, two JSON types, and
3566
+ * arithmetic on the second one silently concatenates. Parsing whatever looks
3567
+ * numeric would fix that and break something worse: a `min(sku)` of `"00123"`
3568
+ * would come back as `123`, a different value, and no NaN check would catch it.
3569
+ *
3570
+ * Exported to be tested: the parsing itself needs a database, this decision
3571
+ * does not.
3572
+ */
3573
+ function numericAggregateAliases(aggregates, properties) {
3574
+ const isNumberField = (field) => Boolean(field) && properties[field]?.type === "number";
3575
+ return new Set(aggregates.filter((a) => a.fn === "count" || a.fn === "sum" || a.fn === "avg" || (a.fn === "min" || a.fn === "max") && isNumberField(a.field)).map((a) => a.alias));
3576
+ }
3518
3577
  var FetchService = class FetchService {
3519
3578
  db;
3520
3579
  registry;
@@ -3909,7 +3968,7 @@ var FetchService = class FetchService {
3909
3968
  row[key] = null;
3910
3969
  continue;
3911
3970
  }
3912
- const [shaped] = this.shapeRelatedRows([{ ...related.values }], node, targetCollection);
3971
+ const [shaped] = this.shapeRelatedRows([toRestValues({ ...related.values }, targetCollection)], node, targetCollection);
3913
3972
  row[key] = shaped;
3914
3973
  loaded.push(shaped);
3915
3974
  }
@@ -3917,7 +3976,7 @@ var FetchService = class FetchService {
3917
3976
  const results = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation, narrow);
3918
3977
  for (const row of addressable) {
3919
3978
  const related = results.get(String(addressOf(row))) ?? [];
3920
- const shaped = this.shapeRelatedRows(related.map((e) => ({ ...e.values })), node, targetCollection);
3979
+ const shaped = this.shapeRelatedRows(related.map((e) => toRestValues({ ...e.values }, targetCollection)), node, targetCollection);
3921
3980
  row[key] = shaped;
3922
3981
  loaded.push(...shaped);
3923
3982
  }
@@ -4355,7 +4414,7 @@ var FetchService = class FetchService {
4355
4414
  if (options.limit) query = query.limit(options.limit);
4356
4415
  }
4357
4416
  const rows = await query;
4358
- const numericAliases = new Set(options.aggregates.filter((a) => a.fn === "count" || a.fn === "sum" || a.fn === "avg").map((a) => a.alias));
4417
+ const numericAliases = numericAggregateAliases(options.aggregates, collection?.properties ?? {});
4359
4418
  return rows.map((row) => {
4360
4419
  const out = { ...row };
4361
4420
  for (const alias of numericAliases) {
@@ -5120,7 +5179,7 @@ var PersistService = class {
5120
5179
  } catch (error) {
5121
5180
  throw this.toUserFriendlyError(error, collection.slug, collection);
5122
5181
  }
5123
- const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
5182
+ const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId, { withDeleted: true });
5124
5183
  if (!finalEntity) throw new Error("Could not fetch row after save.");
5125
5184
  return finalEntity;
5126
5185
  }