@rebasepro/common 0.12.0 → 0.12.1-canary.g009ed95

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
@@ -1,5 +1,5 @@
1
- import { ANONYMOUS_USER_ID, CANONICAL_TO_REST, DEFAULT_DATA_SOURCE_KEY, EntityReference, EntityRelation, NULL_OPS, REST_TO_CANONICAL, getDataSourceCapabilities, getDeclaredSubcollections, isManyToMany, isPostgresCollectionConfig, isRelationalCollectionConfig, policy, toCanonicalOp } from "@rebasepro/types";
2
- import { deepClone, generateForeignKeyName, getIn, getPolicyOperations, isDefaultFieldConfigId, mergeDeep, prettifyIdentifier, randomString, removeFunctions, toSnakeCase } from "@rebasepro/utils";
1
+ import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, CANONICAL_TO_REST, DEFAULT_DATA_SOURCE_KEY, EntityReference, EntityRelation, NULL_OPS, REST_TO_CANONICAL, getDataSourceCapabilities, getDeclaredSubcollections, isAnonymousUid, isManyToMany, isPostgresCollectionConfig, isRelationalCollectionConfig, policy, toCanonicalOp } from "@rebasepro/types";
2
+ import { deepClone, generateForeignKeyName, getIn, getPolicyNamesForRules, getPolicyOperations, isDefaultFieldConfigId, mergeDeep, prettifyIdentifier, randomString, removeFunctions, toSnakeCase } from "@rebasepro/utils";
3
3
  import jsonLogic from "json-logic-js";
4
4
  import { deepEqual } from "fast-equals";
5
5
  //#region src/util/common.ts
@@ -330,6 +330,39 @@ function resolvePrimaryKeys(collection) {
330
330
  return [];
331
331
  }
332
332
  //#endregion
333
+ //#region src/util/email.ts
334
+ /**
335
+ * Email normalization — one implementation, because the database enforces it.
336
+ *
337
+ * `ensureAuthTablesExist` puts a `UNIQUE INDEX ON users (lower(email))` on the
338
+ * auth table. That index decides what "the same address" means, and it does not
339
+ * trim: to Postgres, `' foo@bar.com'` and `'foo@bar.com'` are two addresses and
340
+ * both may exist. So every write that reaches the column has to agree with
341
+ * every read, exactly, or the two disagree in the one direction that matters —
342
+ * a row that exists and cannot be found.
343
+ *
344
+ * That is not hypothetical. The lookup path trimmed and the admin create paths
345
+ * did not, so a user created through `POST /api/data/users` or
346
+ * `POST /api/auth/admin/users` with a stray space was stored untrimmed,
347
+ * survived the unique index alongside the real address, and was unreachable by
348
+ * login forever after. The HTTP auth routes were unaffected only because Zod's
349
+ * `.email()` happens to reject surrounding whitespace — a guard on a different
350
+ * layer, for a different reason, that the admin paths do not sit behind.
351
+ *
352
+ * It lives in `common` because `server`, `server-postgres` and `server-mongo`
353
+ * all write this column and must agree exactly, and `common` is the only
354
+ * package all three already depend on.
355
+ */
356
+ /**
357
+ * Canonical form of an email address: trimmed, lower-cased.
358
+ *
359
+ * Non-strings pass through untouched, so this is safe to apply to a value out
360
+ * of a partial update payload whose type is not known yet.
361
+ */
362
+ function normalizeEmail(email) {
363
+ return typeof email === "string" ? email.trim().toLowerCase() : email;
364
+ }
365
+ //#endregion
333
366
  //#region src/util/enums.ts
334
367
  function enumToObjectEntries(enumValues) {
335
368
  if (Array.isArray(enumValues)) return enumValues;
@@ -393,8 +426,7 @@ function fullPathToCollectionSegments(path) {
393
426
  function resolveRelation(relation, sourceCollection, propertyKey) {
394
427
  const target = relation.target;
395
428
  if (typeof target !== "function") throw new Error(`Relation${relation.relationName ? ` '${relation.relationName}'` : ""} on '${sourceCollection.slug}' has no \`target\`. Give it a thunk: \`target: () => otherCollection\`.`);
396
- const targetCollection = target();
397
- if (!targetCollection?.slug) throw new Error(`Relation${relation.relationName ? ` '${relation.relationName}'` : ""} on '${sourceCollection.slug}' has a \`target\` that did not resolve to a collection.`);
429
+ const targetCollection = callTarget(relation, sourceCollection, propertyKey, target);
398
430
  const relationName = relation.relationName ?? propertyKey ?? toSnakeCase(targetCollection.slug);
399
431
  const shared = {
400
432
  relationName,
@@ -421,7 +453,8 @@ function resolveRelation(relation, sourceCollection, propertyKey) {
421
453
  cardinality: "one",
422
454
  writable: true,
423
455
  shared: false,
424
- foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName)
456
+ foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),
457
+ sourceKey: relation.sourceKey
425
458
  };
426
459
  case "hasMany": return {
427
460
  ...shared,
@@ -429,7 +462,8 @@ function resolveRelation(relation, sourceCollection, propertyKey) {
429
462
  cardinality: "many",
430
463
  writable: true,
431
464
  shared: false,
432
- foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName)
465
+ foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),
466
+ sourceKey: relation.sourceKey
433
467
  };
434
468
  case "manyToMany": {
435
469
  const sourceTable = getTableName(sourceCollection);
@@ -458,6 +492,43 @@ function resolveRelation(relation, sourceCollection, propertyKey) {
458
492
  default: throw new Error(`Unknown relation kind: ${JSON.stringify(relation)}`);
459
493
  }
460
494
  }
495
+ /** How this relation is addressed in an error message, before it has a resolved name. */
496
+ function describe(relation, sourceCollection, propertyKey) {
497
+ const name = relation.relationName ?? propertyKey;
498
+ return `Relation${name ? ` '${name}'` : ""} on '${sourceCollection.slug}'`;
499
+ }
500
+ /**
501
+ * Call the `target` thunk, and translate the two ways an import cycle breaks it
502
+ * into an error that names the cause.
503
+ *
504
+ * The thunk exists to defer the reference until every module has finished
505
+ * evaluating, and for a cycle that closes at import time it does. What it cannot
506
+ * defer is a cycle that leaves the binding permanently unusable, and there are
507
+ * two shapes of that:
508
+ *
509
+ * - **ESM/TDZ.** `const` and `class` bindings in a not-yet-evaluated module are
510
+ * in the temporal dead zone, so reading one throws `ReferenceError: x is not
511
+ * defined`. The stack points at the thunk — a one-line arrow function that is
512
+ * obviously fine — and says nothing about the cycle that made it throw.
513
+ * - **CJS interop.** The half-initialised module object has no `default` yet,
514
+ * the import resolves to `undefined`, and the thunk returns it without
515
+ * complaint. That one used to surface here as "did not resolve to a
516
+ * collection", which is true and unhelpful.
517
+ *
518
+ * Both mean the same thing, and the fix for both is the same: break the cycle,
519
+ * or move the relation into the collection that does not close it.
520
+ */
521
+ function callTarget(relation, sourceCollection, propertyKey, target) {
522
+ let targetCollection;
523
+ try {
524
+ targetCollection = target();
525
+ } catch (error) {
526
+ if (error instanceof ReferenceError) throw new Error(`${describe(relation, sourceCollection, propertyKey)} targets a collection that is not initialized yet — almost always an import cycle between the two collection files. Break the cycle (move the shared piece into a third module, or import the target lazily) so the target's module finishes evaluating before the registry is built.`, { cause: error });
527
+ throw error;
528
+ }
529
+ if (!targetCollection?.slug) throw new Error(`${describe(relation, sourceCollection, propertyKey)} has a \`target\` that resolved to ${targetCollection === void 0 ? "`undefined`" : "something that is not a collection"}. ` + (targetCollection === void 0 ? "Under CommonJS interop an import cycle resolves the default import to `undefined`, so check whether this collection and its target import each other. Otherwise the thunk is returning the wrong value — it must return the collection itself, not a promise or a module." : "The thunk must return a collection config with a `slug`."));
530
+ return targetCollection;
531
+ }
461
532
  //#endregion
462
533
  //#region src/util/relations.ts
463
534
  /**
@@ -957,7 +1028,12 @@ var UID_NOT_NULL = /auth\.uid\(\)\s+IS\s+NOT\s+NULL/i;
957
1028
  * is how the trusted *server* context is recognised), so:
958
1029
  *
959
1030
  * - `auth.uid() IS NOT NULL` is a tautology on the user path, and
960
- * - `auth.uid() != 'anon'` compares against a string no caller ever has.
1031
+ * - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
1032
+ * other. This one is not hypothetical and was not only a foreign habit:
1033
+ * rebase's own request path reported `'anon'` while everything that compiled
1034
+ * or checked a policy used `'anonymous'`, so whichever literal an author
1035
+ * picked, half the anonymous callers walked through. See
1036
+ * {@link ANONYMOUS_USER_IDS}.
961
1037
  *
962
1038
  * Either one turns a lockdown into a full grant, and neither looks wrong. No
963
1039
  * real user id is ever one of these literals, and a user-context request is
@@ -995,7 +1071,7 @@ function findAnonymousGrants(expr) {
995
1071
  found.push({
996
1072
  pattern: "foreign-uid-literal",
997
1073
  detail: literal.value,
998
- explanation: `'${literal.value}' is a ${platform} convention. Rebase reports an anonymous request as '${ANONYMOUS_USER_ID}', so comparing against '${literal.value}' passes for every caller. Use \`condition: policy.authenticated()\` to mean "signed in".`
1074
+ explanation: `'${literal.value}' is a ${platform} convention. Rebase reports an anonymous request as '${ANONYMOUS_USER_ID}', so comparing against '${literal.value}' passes for every caller. Use \`condition: policy.authenticated()\` to mean "signed in" — it compiles to NOT IN (${ANONYMOUS_USER_IDS.map((v) => `'${v}'`).join(", ")}), covering every spelling rebase has reported rather than whichever one you remember.`
999
1075
  });
1000
1076
  return;
1001
1077
  }
@@ -1090,7 +1166,7 @@ function compile(expr, scope) {
1090
1166
  }
1091
1167
  case "rolesOverlap": return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
1092
1168
  case "rolesContain": return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
1093
- case "authenticated": return `auth.uid() IS NOT NULL AND auth.uid() <> ${quoteLiteral(ANONYMOUS_USER_ID)}`;
1169
+ case "authenticated": return `auth.uid() IS NOT NULL AND auth.uid() NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
1094
1170
  case "serverContext": return "auth.uid() IS NULL";
1095
1171
  case "existsIn": return compileExistsIn(expr, scope);
1096
1172
  case "raw": return expr.sql.replace(/\{(\w+)\}/g, (_, col) => `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
@@ -1187,7 +1263,7 @@ function evaluatePolicy(expr, ctx) {
1187
1263
  const userRoles = ctx.roles ?? [];
1188
1264
  return expr.roles.every((r) => r === "public" || userRoles.includes(r));
1189
1265
  }
1190
- case "authenticated": return ctx.uid != null && ctx.uid !== ANONYMOUS_USER_ID;
1266
+ case "authenticated": return ctx.uid != null && !isAnonymousUid(ctx.uid);
1191
1267
  case "serverContext": return false;
1192
1268
  case "existsIn": return "unknown";
1193
1269
  case "raw": return "unknown";
@@ -1356,35 +1432,12 @@ function canDeleteEntity(collection, authContext, path, entity) {
1356
1432
  //#endregion
1357
1433
  //#region src/util/builders.ts
1358
1434
  /**
1359
- * @deprecated Use {@link defineCollection} instead — it infers property
1360
- * types automatically (autocomplete on `titleProperty`, `sort`,
1361
- * `propertiesOrder`, callbacks) without manual generics.
1362
- * `buildCollection` is kept for FireCMS migration compatibility and will
1363
- * be removed before 1.0.
1364
- *
1365
- * @group Builder
1366
- */
1367
- function buildCollection(collection) {
1368
- return collection;
1369
- }
1370
- /**
1371
1435
  * Implementation — delegates to the correct overload at the type level.
1372
1436
  * At runtime this is a plain identity function.
1373
1437
  */
1374
1438
  function defineCollection(collection) {
1375
1439
  return collection;
1376
1440
  }
1377
- /**
1378
- * @deprecated Use plain typed property objects with {@link defineCollection}
1379
- * instead — `defineCollection` infers property types automatically, making
1380
- * this wrapper unnecessary. `buildProperty` is kept for FireCMS migration
1381
- * compatibility and will be removed before 1.0.
1382
- *
1383
- * @group Builder
1384
- */
1385
- function buildProperty(property) {
1386
- return property;
1387
- }
1388
1441
  //#endregion
1389
1442
  //#region src/util/storage.ts
1390
1443
  /**
@@ -1653,6 +1706,26 @@ function getInjectedSecurityRules(collection) {
1653
1706
  const explicitCount = (collection.securityRules ?? []).length;
1654
1707
  return getEffectiveSecurityRules(collection).slice(explicitCount);
1655
1708
  }
1709
+ /**
1710
+ * Every policy name `rebase db push` would write for a collection.
1711
+ *
1712
+ * This is the answer to "did the codebase produce this live policy?", and it is
1713
+ * more than `securityRules.map(r => r.name)` for two reasons:
1714
+ *
1715
+ * - a rule without an explicit `name` compiles to `<table>_<op>_<hash>`, one
1716
+ * per operation, so comparing `rule.name` to `policyname` never matches it;
1717
+ * - the generator also injects the safe-by-default baseline
1718
+ * (`<table>_default_admin_*`), which is in no collection's `securityRules`.
1719
+ *
1720
+ * Every UI that flags drift has to get both right, and each one that derived it
1721
+ * by hand got a different subset — which is how four policies *Rebase itself
1722
+ * wrote* came to be badged as hand-written drift on every table in a project,
1723
+ * with a button offering to import them back into the codebase that produced
1724
+ * them. There is one derivation now, and this is it.
1725
+ */
1726
+ function getGeneratedPolicyNames(collection) {
1727
+ return getPolicyNamesForRules(getEffectiveSecurityRules(collection), getTableName(collection));
1728
+ }
1656
1729
  //#endregion
1657
1730
  //#region src/util/junction-policies.ts
1658
1731
  var SERVER_OR_ADMIN_EXPR = policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
@@ -1952,7 +2025,7 @@ function buildConditionContext(params) {
1952
2025
  * Maps a PostgreSQL column data type to a Rebase property type.
1953
2026
  */
1954
2027
  function pgTypeToRebaseProperty(column) {
1955
- const { column_name, data_type, udt_name, is_nullable, column_default, enum_values } = column;
2028
+ const { column_name, data_type, udt_name, is_nullable, column_default, character_maximum_length, enum_values } = column;
1956
2029
  const required = is_nullable === "NO";
1957
2030
  const prettifiedName = prettifyIdentifier(column_name);
1958
2031
  const isAutoId = column_default != null && (column_default.includes("nextval") || column_default.includes("gen_random_uuid") || column_default.includes("uuid_generate") || column_default.includes("identity"));
@@ -1976,11 +2049,15 @@ function pgTypeToRebaseProperty(column) {
1976
2049
  let colType = "varchar";
1977
2050
  if (dt === "text" || dt === "citext") colType = "text";
1978
2051
  if (dt === "char" || dt === "character") colType = "char";
2052
+ const declaredLength = colType === "text" ? null : character_maximum_length;
1979
2053
  const prop = {
1980
2054
  type: "string",
1981
2055
  name: prettifiedName,
1982
2056
  columnType: colType,
1983
- validation: required ? { required: true } : void 0
2057
+ validation: required || declaredLength ? {
2058
+ ...required ? { required: true } : {},
2059
+ ...declaredLength ? { max: declaredLength } : {}
2060
+ } : void 0
1984
2061
  };
1985
2062
  if (isAutoId) prop.isId = "manual";
1986
2063
  return prop;
@@ -2184,6 +2261,34 @@ function buildCollectionFromTableMetadata(tableName, metadata) {
2184
2261
  };
2185
2262
  }
2186
2263
  //#endregion
2264
+ //#region src/util/string-column-length.ts
2265
+ /**
2266
+ * The length a bounded string column is declared with when the property does
2267
+ * not say. Historical: it is what the DDL generator hardcoded, kept so that
2268
+ * regenerating an existing schema does not silently redefine its columns.
2269
+ */
2270
+ var DEFAULT_STRING_COLUMN_LENGTH = 255;
2271
+ /**
2272
+ * How wide a `varchar`/`char` column should be for a given property.
2273
+ *
2274
+ * One definition, three call sites, because they used to disagree. For the same
2275
+ * `columnType: "varchar"` property the DDL generator emitted `VARCHAR(255)`
2276
+ * while the Drizzle generator emitted a bare `varchar("col")` — which Postgres
2277
+ * reads as *unbounded* — so which of the two you ran decided whether the column
2278
+ * had a limit at all. Introspection then dropped the length entirely, so reading
2279
+ * an existing `character varying(500)` column back and regenerating it produced
2280
+ * a `VARCHAR(255)`: a silent narrowing of a column with data already in it.
2281
+ *
2282
+ * `validation.max` is the property's own statement about how long the value may
2283
+ * be, so it is the only sensible source for the column's width — and it keeps
2284
+ * the constraint the database enforces in step with the one the app enforces,
2285
+ * rather than inventing a second, different limit underneath it.
2286
+ */
2287
+ function resolveStringColumnLength(prop) {
2288
+ const max = prop.validation?.max;
2289
+ return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : 255;
2290
+ }
2291
+ //#endregion
2187
2292
  //#region src/data/resolveDataSource.ts
2188
2293
  /**
2189
2294
  * Build a keyed registry from a list of {@link DataSourceDefinition}s.
@@ -3150,7 +3255,7 @@ function rowToEntity(row, slug, primaryKeys = []) {
3150
3255
  };
3151
3256
  }
3152
3257
  /**
3153
- * The relation envelope `toCmsRow` writes where a relation was:
3258
+ * The relation envelope `toFlatRow` writes where a relation was:
3154
3259
  * `{ id, path, __type: "relation", data: { id, path, values } }`. It is the
3155
3260
  * admin's view-model, and the only pipeline that produces one is postgres'.
3156
3261
  */
@@ -3773,6 +3878,6 @@ async function detectJunctionTables(executeSql) {
3773
3878
  return junctionTables;
3774
3879
  }
3775
3880
  //#endregion
3776
- export { COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, JUNCTION_TABLES_SQL, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, RebasePaginationError, and, buildCollection, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildProperty, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, checkOperation, classifyTable, collectAllPages, cond, createDataSourceRegistry, createPaginationHelpers, createRelationRef, createRelationRefWithData, defaultUsersCollection, defineCollection, deserializeFilter, deserializeLogicalCondition, deserializeOrderBy, detectJunctionTables, embedParentExpression, enumToObjectEntries, evaluateCondition, evaluatePolicy, findAnonymousGrants, findRelation, fullPathToCollectionSegments, getArrayResolvedProperties, getColumnName, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getSubcollections, getTableName, getTableVarName, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, normalizeToEntityRelation, or, paginateFind, parseIdValues, policyToPostgres, registerConditionOperations, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeLogicalCondition, serializeOrderBy, sortProperties, sqlToPolicy, stripCollectionPath, traverseValueProperty, traverseValuesProperties, updateDateAutoValues, wrapAsEntityData, wrapAsSdkData };
3881
+ export { COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_STRING_COLUMN_LENGTH, JUNCTION_TABLES_SQL, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, RebasePaginationError, and, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, checkOperation, classifyTable, collectAllPages, cond, createDataSourceRegistry, createPaginationHelpers, createRelationRef, createRelationRefWithData, defaultUsersCollection, defineCollection, deserializeFilter, deserializeLogicalCondition, deserializeOrderBy, detectJunctionTables, embedParentExpression, enumToObjectEntries, evaluateCondition, evaluatePolicy, findAnonymousGrants, findRelation, fullPathToCollectionSegments, getArrayResolvedProperties, getColumnName, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getSubcollections, getTableName, getTableVarName, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, normalizeEmail, normalizeToEntityRelation, or, paginateFind, parseIdValues, policyToPostgres, registerConditionOperations, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeLogicalCondition, serializeOrderBy, sortProperties, sqlToPolicy, stripCollectionPath, traverseValueProperty, traverseValuesProperties, updateDateAutoValues, wrapAsEntityData, wrapAsSdkData };
3777
3882
 
3778
3883
  //# sourceMappingURL=index.es.js.map