@rebasepro/common 0.27.0 → 0.28.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/dist/index.es.js +4 -3
- package/dist/util/entities.es.js +1 -4
- package/dist/util/entities.es.js.map +1 -1
- package/dist/util/identity.d.ts +8 -0
- package/dist/util/identity.es.js +36 -6
- package/dist/util/identity.es.js.map +1 -1
- package/dist/util/index.d.ts +1 -0
- package/dist/util/policy/evaluatePolicy.es.js +1 -1
- package/dist/util/policy/evaluatePolicy.es.js.map +1 -1
- package/dist/util/policy/policyToPostgres.es.js +16 -3
- package/dist/util/policy/policyToPostgres.es.js.map +1 -1
- package/dist/util/policy/sqlToPolicy.es.js +14 -2
- package/dist/util/policy/sqlToPolicy.es.js.map +1 -1
- package/dist/util/resolutions.d.ts +9 -1
- package/dist/util/resolutions.es.js +10 -6
- package/dist/util/resolutions.es.js.map +1 -1
- package/dist/util/resolve-relation.es.js +1 -0
- package/dist/util/resolve-relation.es.js.map +1 -1
- package/dist/util/table-ddl-warning.d.ts +31 -0
- package/dist/util/table-ddl-warning.es.js +151 -0
- package/dist/util/table-ddl-warning.es.js.map +1 -0
- package/package.json +3 -3
package/dist/index.es.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "./util/common.es.js";
|
|
2
2
|
import { applyDefaultValuesOnCreate, createRelationRef, createRelationRefWithData, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getReferenceFrom, getRelationFrom, isPropertyBuilder, normalizeToEntityRelation, sanitizeData, traverseValueProperty, traverseValuesProperties, updateDateAutoValues, updateUserAutoValues, withRelationReferencesOnly } from "./util/entities.es.js";
|
|
3
3
|
import { getPrimaryKeys, sortCollectionsBySlug, sortProperties } from "./util/collections.es.js";
|
|
4
|
-
import { COMPOSITE_ID_SEPARATOR, buildCompositeId, getDeclaredPrimaryKeys, isAddressableId, parseIdValues, resolvePrimaryKeys } from "./util/identity.es.js";
|
|
4
|
+
import { COMPOSITE_ID_SEPARATOR, buildCompositeId, getDeclaredPrimaryKeys, hasEntityId, isAddressableId, parseIdValues, resolvePrimaryKeys } from "./util/identity.es.js";
|
|
5
5
|
import { normalizeEmail } from "./util/email.es.js";
|
|
6
6
|
import { enumToObjectEntries, getLabelOrConfigFrom } from "./util/enums.es.js";
|
|
7
7
|
import { COLLECTION_PATH_SEPARATOR, fullPathToCollectionSegments, segmentsToStrippedPath, stripCollectionPath } from "./util/paths.es.js";
|
|
@@ -33,6 +33,8 @@ import { DEFAULT_STRING_COLUMN_LENGTH, resolveStringColumnLength } from "./util/
|
|
|
33
33
|
import { REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, revokeInternalTableAccess, revokeInternalTableSql } from "./util/internal-tables.es.js";
|
|
34
34
|
import { firstSqlRow, sqlRows } from "./util/sql-rows.es.js";
|
|
35
35
|
import { redactSqlLiterals } from "./util/sql-redaction.es.js";
|
|
36
|
+
import { JUNCTION_TABLES_SQL, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, classifyTable, detectJunctionTables, isRebaseInternalTable } from "./table-classification.es.js";
|
|
37
|
+
import { findTableDdl, tableDdlMessage, warnOnTableDdl } from "./util/table-ddl-warning.es.js";
|
|
36
38
|
import { copyCarriesRelation, getCopyValues } from "./util/copy.es.js";
|
|
37
39
|
import { untrustedEnvelope } from "./util/untrusted-envelope.es.js";
|
|
38
40
|
import { resetDeprecationWarningsForTests, warnDeprecated } from "./util/deprecation.es.js";
|
|
@@ -55,5 +57,4 @@ import { aggregateAlias, buildRebaseData, buildSdkData, wrapAsEntityData, wrapAs
|
|
|
55
57
|
import { createCallContext } from "./data/call-context.es.js";
|
|
56
58
|
import { createAccessorSlugResolver, toCamelCase, toCollectionAccessor, toPascalCase, toSafeIdentifier } from "./data/collection-accessor.es.js";
|
|
57
59
|
import { buildRoutedRebaseData } from "./data/buildRoutedRebaseData.es.js";
|
|
58
|
-
|
|
59
|
-
export { ADMIN_ROLE, CALLBACK_REJECTED, COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, CursorError, CursorMismatchError, DEFAULT_DOWNLOAD_URL_CACHE_ENTRIES, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_SOFT_DELETE_FIELD, DEFAULT_STRING_COLUMN_LENGTH, DOWNLOAD_METADATA_BATCH_MAX, DOWNLOAD_URL_CONCURRENCY, ETAG_KEY, IncludeSpecError, JUNCTION_TABLES_SQL, MAX_LOGICAL_NESTING_DEPTH, OrderBySpecError, PAGE_WITH_OFFSET_MESSAGE, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, RESERVED_QUERY_KEYS, RESUMABLE_UPLOAD_THRESHOLD, RebasePaginationError, TENANT_INDEX_REASON, UnknownFilterOperatorError, aggregateAlias, and, apiHiddenFieldNames, applyDefaultValuesOnCreate, assertBeforeQueryIsPostgresOnly, authSecretsMissingExclusion, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, buildTenantSecurityRule, callbackRefusal, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, canReadField, canWriteField, checkOperation, classifyTable, collectAllPages, compileRulePolicies, composeCallbacks, cond, copyCarriesRelation, createAccessorSlugResolver, createBatchLoader, createCallContext, createConcurrencyLimit, createDataSourceRegistry, createDownloadUrlCache, createPaginationHelpers, createRelationRef, createRelationRefWithData, cursorToStartAfter, decodeCursor, defaultUsersCollection, defineCollection, denormalizeInclude, deserializeFilter, deserializeInclude, deserializeLogicalCondition, deserializeOrderBy, deserializeOrderByList, detectJunctionTables, effectiveAccess, embedParentExpression, encodeCursor, enumToObjectEntries, etagOf, evaluateCondition, evaluatePolicy, fieldKeyForColumn, findAnonymousGrants, findRelation, firstSqlRow, fullPathToCollectionSegments, getArrayResolvedProperties, getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getColumnName, getCopyValues, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionConfigForRelation, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getRelationTargetPath, getSubcollections, getTableName, getTableVarName, getTenantConfig, hasFieldAccessRules, includableRelationNames, includePaths, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, isRelationRequired, isRelationalCollection, mergeIncludeSpecs, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeOrderBy, normalizeToEntityRelation, not, or, paginateFind, parseIdValues, parseOrderBySpecStrict, policyToPostgres, policyToSecurityRule, primaryOrderBy, queryableFieldNames, reconcileCursorOrder, redactSqlLiterals, registerConditionOperations, relationDeclaringProperty, relationalCollections, requireCallbackClient, requireCallbackCollection, resetDeprecationWarningsForTests, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveFindWindow, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, resolveTenantWrite, restoresSoftDeletedRow, restrictedFieldNames, revokeInternalTableAccess, revokeInternalTableSql, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeInclude, serializeLogicalCondition, serializeOrderBy, softDeleteFieldOf, sortCollectionsBySlug, sortProperties, sqlRows, sqlToPolicy, stripCollectionPath, tenantBypassRoles, tenantPolicyName, tenantScopeExpression, toCallbackError, toCamelCase, toCollectionAccessor, toFilterTuples, toPascalCase, toSafeIdentifier, topLevelIncludeNames, traverseValueProperty, traverseValuesProperties, unavailableStorageSource, unknownAggregateField, unknownFilterField, unknownIncludeRelation, unknownOrderByField, unknownProjectionField, unknownRelationTargetField, unknownVectorProperty, untrustedEnvelope, updateDateAutoValues, updateUserAutoValues, uploadResumable, vectorPropertyNames, warnDeprecated, withETag, withRelationReferencesOnly, wrapAsEntityData, wrapAsSdkData };
|
|
60
|
+
export { ADMIN_ROLE, CALLBACK_REJECTED, COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, CursorError, CursorMismatchError, DEFAULT_DOWNLOAD_URL_CACHE_ENTRIES, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_SOFT_DELETE_FIELD, DEFAULT_STRING_COLUMN_LENGTH, DOWNLOAD_METADATA_BATCH_MAX, DOWNLOAD_URL_CONCURRENCY, ETAG_KEY, IncludeSpecError, JUNCTION_TABLES_SQL, MAX_LOGICAL_NESTING_DEPTH, OrderBySpecError, PAGE_WITH_OFFSET_MESSAGE, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, RESERVED_QUERY_KEYS, RESUMABLE_UPLOAD_THRESHOLD, RebasePaginationError, TENANT_INDEX_REASON, UnknownFilterOperatorError, aggregateAlias, and, apiHiddenFieldNames, applyDefaultValuesOnCreate, assertBeforeQueryIsPostgresOnly, authSecretsMissingExclusion, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, buildTenantSecurityRule, callbackRefusal, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, canReadField, canWriteField, checkOperation, classifyTable, collectAllPages, compileRulePolicies, composeCallbacks, cond, copyCarriesRelation, createAccessorSlugResolver, createBatchLoader, createCallContext, createConcurrencyLimit, createDataSourceRegistry, createDownloadUrlCache, createPaginationHelpers, createRelationRef, createRelationRefWithData, cursorToStartAfter, decodeCursor, defaultUsersCollection, defineCollection, denormalizeInclude, deserializeFilter, deserializeInclude, deserializeLogicalCondition, deserializeOrderBy, deserializeOrderByList, detectJunctionTables, effectiveAccess, embedParentExpression, encodeCursor, enumToObjectEntries, etagOf, evaluateCondition, evaluatePolicy, fieldKeyForColumn, findAnonymousGrants, findRelation, findTableDdl, firstSqlRow, fullPathToCollectionSegments, getArrayResolvedProperties, getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getColumnName, getCopyValues, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionConfigForRelation, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getRelationTargetPath, getSubcollections, getTableName, getTableVarName, getTenantConfig, hasEntityId, hasFieldAccessRules, includableRelationNames, includePaths, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, isRelationRequired, isRelationalCollection, mergeIncludeSpecs, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeOrderBy, normalizeToEntityRelation, not, or, paginateFind, parseIdValues, parseOrderBySpecStrict, policyToPostgres, policyToSecurityRule, primaryOrderBy, queryableFieldNames, reconcileCursorOrder, redactSqlLiterals, registerConditionOperations, relationDeclaringProperty, relationalCollections, requireCallbackClient, requireCallbackCollection, resetDeprecationWarningsForTests, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveFindWindow, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, resolveTenantWrite, restoresSoftDeletedRow, restrictedFieldNames, revokeInternalTableAccess, revokeInternalTableSql, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeInclude, serializeLogicalCondition, serializeOrderBy, softDeleteFieldOf, sortCollectionsBySlug, sortProperties, sqlRows, sqlToPolicy, stripCollectionPath, tableDdlMessage, tenantBypassRoles, tenantPolicyName, tenantScopeExpression, toCallbackError, toCamelCase, toCollectionAccessor, toFilterTuples, toPascalCase, toSafeIdentifier, topLevelIncludeNames, traverseValueProperty, traverseValuesProperties, unavailableStorageSource, unknownAggregateField, unknownFilterField, unknownIncludeRelation, unknownOrderByField, unknownProjectionField, unknownRelationTargetField, unknownVectorProperty, untrustedEnvelope, updateDateAutoValues, updateUserAutoValues, uploadResumable, vectorPropertyNames, warnDeprecated, warnOnTableDdl, withETag, withRelationReferencesOnly, wrapAsEntityData, wrapAsSdkData };
|
package/dist/util/entities.es.js
CHANGED
|
@@ -251,10 +251,7 @@ function traverseValueProperty(inputValue, property, operation) {
|
|
|
251
251
|
else if (property.type === "array") {
|
|
252
252
|
const of = property.of;
|
|
253
253
|
if (of && Array.isArray(inputValue) && !Array.isArray(of)) value = inputValue.map((e) => traverseValueProperty(e, of, operation));
|
|
254
|
-
else if (of && Array.isArray(inputValue) && Array.isArray(of)) value = inputValue.map((e, i) =>
|
|
255
|
-
if (i < of.length) return traverseValueProperty(e, of[i], operation);
|
|
256
|
-
return null;
|
|
257
|
-
}).filter(Boolean);
|
|
254
|
+
else if (of && Array.isArray(inputValue) && Array.isArray(of)) value = inputValue.map((e, i) => i < of.length ? traverseValueProperty(e, of[i], operation) : e);
|
|
258
255
|
else if (property.oneOf && Array.isArray(inputValue)) {
|
|
259
256
|
const typeField = property.oneOf?.typeField ?? DEFAULT_ONE_OF_TYPE;
|
|
260
257
|
const valueField = property.oneOf?.valueField ?? DEFAULT_ONE_OF_VALUE;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entities.es.js","names":[],"sources":["../../src/util/entities.ts"],"sourcesContent":["import {\n DataType,\n Entity,\n EntityReference,\n EntityRelation,\n EntityStatus,\n EntityValues,\n Properties,\n Property\n} from \"@rebasepro/types\";\nimport { DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from \"./common\";\nimport { mergeDeep } from \"@rebasepro/utils\";\n\nexport function isPropertyBuilder(property?: Property) {\n return typeof property?.dynamicProps === \"function\";\n}\n\n/**\n * What a form opens with: a value for every property it can write.\n *\n * `excludeFromApi` columns are left out, and that is the whole of the rule —\n * they are not part of the API surface in either direction, so there is nothing\n * for a form to open showing and nothing it may send back. Including them was\n * not cosmetic: the baseline is what gets submitted, so a new record carried\n * `passwordHash: null` and `emailVerificationToken: null` into the create, and\n * the server refused the whole write with \"these columns are the server's to\n * set\" — the users collection could not be added to from the panel at all. The\n * fields were invisible on screen (`admin.disabled.hidden`), which is what made\n * the error read as being about the roles the operator *had* just edited.\n *\n * Server-side defaulting does not come through here: `applyDefaultValuesOnCreate`\n * asks each property for its own default, so an excluded column with a declared\n * `defaultValue` is still filled in on an in-process write.\n */\nexport function getDefaultValuesFor<M extends Record<string, unknown>>(properties: Properties): Partial<EntityValues<M>> {\n if (!properties) return {};\n return Object.entries(properties)\n .map(([key, property]) => {\n if (!property) return {};\n if ((property as Property).excludeFromApi) return {};\n const value = getDefaultValueFor(property);\n return value === undefined ? {} : { [key]: value };\n })\n .reduce((a, b) => ({ ...a,\n...b }), {}) as EntityValues<M>;\n}\n\nexport function getDefaultValueFor(property?: Property): unknown {\n if (!property) return undefined;\n if (isPropertyBuilder(property)) return undefined;\n // `defaultValue !== undefined`, not truthiness. The test used to be\n // `property.defaultValue || property.defaultValue === null`, which special-\n // cased exactly one falsy value and dropped the rest: `defaultValue: 0`\n // fell through to the per-type default and became `null`, `defaultValue: \"\"`\n // became `null`, and `defaultValue: false` survived only by coincidence\n // (the per-type default for a boolean is also `false`). A default of zero\n // is the most ordinary default a number column has.\n if (property.defaultValue !== undefined) {\n return property.defaultValue;\n } else if (property.type === \"map\" && property.properties) {\n const defaultValuesFor = getDefaultValuesFor(property.properties as Properties);\n if (Object.keys(defaultValuesFor).length === 0) return undefined;\n return defaultValuesFor;\n } else {\n return getDefaultValueFortype(property.type);\n }\n}\n\nexport function getDefaultValueFortype(type: DataType): unknown {\n if (type === \"string\") {\n return null;\n } else if (type === \"number\") {\n return null;\n } else if (type === \"boolean\") {\n return false;\n } else if (type === \"date\") {\n return null;\n } else if (type === \"array\") {\n return [];\n } else if (type === \"map\") {\n return {};\n } else if (type === \"vector\") {\n return null;\n } else if (type === \"binary\") {\n return null;\n } else {\n return null;\n }\n}\n\n/**\n * Update the automatic values in a entity before save\n * @group Driver\n */\nexport function updateDateAutoValues<M extends Record<string, unknown>>({\n inputValues,\n properties,\n status,\n timestampNowValue\n}:\n {\n inputValues: Partial<EntityValues<M>>,\n properties: Properties,\n status: EntityStatus,\n timestampNowValue: unknown\n }): EntityValues<M> {\n return traverseValuesProperties(\n inputValues,\n properties,\n (inputValue, property) => {\n if (property.type === \"date\") {\n if (status === \"existing\" && property.autoValue === \"on_update\") {\n return timestampNowValue;\n } else if ((status === \"new\" || status === \"copy\") &&\n (property.autoValue === \"on_update\" || property.autoValue === \"on_create\")) {\n return timestampNowValue;\n } else {\n return inputValue;\n }\n } else {\n return inputValue;\n }\n }\n ) ?? {} as M;\n}\n\n/**\n * Stamp the acting user's uid into the `user_on_create` / `user_on_update`\n * columns a collection declares.\n *\n * A deliberate sibling of {@link updateDateAutoValues} rather than another\n * branch inside it. The two share a shape and nothing else: one takes an\n * instant the server generates and the other takes an identity the request\n * carries, so overloading the timestamp function would have meant threading a\n * second, unrelated argument through every one of its callers and letting a\n * `date` property and a `string` property compete for the same `autoValue`\n * union. Called side by side in the driver.\n *\n * The stamped value overwrites whatever arrived in the body. A caller who can\n * set `createdBy` is a caller who can attribute their write to somebody else,\n * which is the one thing an audit column must not allow.\n *\n * `uid` is `undefined` for an anonymous request, a service token or an\n * in-process write; the column is set to an explicit `null` there. Explicit\n * matters on an update: leaving the key absent would keep whatever uid the\n * column already held, so an anonymous edit would be recorded as the previous\n * editor's. Refusing that write outright is `required`'s job, not this\n * function's — see `assertWriteValuesValid`.\n *\n * Top-level properties only, deliberately, unlike {@link updateDateAutoValues}.\n * `traverseValuesProperties` cannot express \"set this key to null\" — a `null`\n * from its operation means \"leave the key out\" — and an audit column nested\n * inside a `map` is not a column at all, so there is nothing down there to\n * stamp.\n *\n * @group Driver\n */\nexport function updateUserAutoValues<M extends Record<string, unknown>>({\n inputValues,\n properties,\n status,\n uid\n}:\n {\n inputValues: Partial<EntityValues<M>>,\n properties: Properties,\n status: EntityStatus,\n uid: string | undefined\n }): EntityValues<M> {\n const result = { ...(inputValues ?? {}) } as Record<string, unknown>;\n for (const [key, property] of Object.entries(properties ?? {})) {\n const prop = property as (Property & { autoValue?: string }) | undefined;\n if (!prop || prop.type !== \"string\") continue;\n const autoValue = prop.autoValue;\n if (autoValue !== \"user_on_create\" && autoValue !== \"user_on_update\") continue;\n // `user_on_create` says nothing about an update: the column holds the\n // creator's uid and this write is not rewriting it.\n if (status === \"existing\" && autoValue === \"user_on_create\") continue;\n // A copy is a new row and gets a new author, exactly as it gets a new\n // `created_on`.\n result[key] = uid ?? null;\n }\n return result as EntityValues<M>;\n}\n\n/**\n * Fill in the `defaultValue`s a create left unset.\n *\n * `defaultValue` was read by exactly one thing: the Studio's form, which uses it\n * to prefill inputs. Every other way into the same collection — the REST create,\n * the SDK, the socket, an import — stored whatever arrived and nothing where the\n * key was absent. So `active: { type: \"boolean\", defaultValue: true }` produced\n * rows with `active` unset through the API and `true` through the panel, from\n * one declaration that reads like a promise about the data.\n *\n * Only genuinely absent keys are filled. An explicit `null` is a caller saying\n * \"no value\", which is a different statement from not mentioning the field, and\n * overwriting it would make the default impossible to opt out of. The exception\n * is a `databaseDefault` column, whose `null` is taken out of the create so the\n * database's own DEFAULT fills it.\n *\n * `getDefaultValuesFor` also invents a per-type default for properties with no\n * `defaultValue` at all (`false` for a boolean, `[]` for an array, `null` for\n * the rest) — right for a form, which must render *something* in every input,\n * and wrong here, where an absent key must stay absent so the column's own\n * DEFAULT applies. Only declared defaults are taken.\n *\n * @param values the caller's payload\n * @param properties the collection's declared properties\n * @group Driver\n */\nexport function applyDefaultValuesOnCreate<M extends Record<string, unknown>>(\n values: Partial<EntityValues<M>> | undefined,\n properties: Properties\n): Partial<EntityValues<M>> {\n if (!properties) return values ?? {};\n const result = { ...(values ?? {}) } as Record<string, unknown>;\n\n for (const [key, property] of Object.entries(properties)) {\n if (!property) continue;\n // The database fills this column, and only when the INSERT leaves it\n // out: a `null` (what a form sends for a field nobody filled in) would\n // be stored as NULL, or refused by the column's NOT NULL.\n if (!isPropertyBuilder(property) && (property as Property).databaseDefault && result[key] === null) {\n delete result[key];\n continue;\n }\n const declared = declaresDefault(property as Property);\n if (!declared) continue;\n // Asked of the property, not read out of `getDefaultValuesFor`: that\n // one answers for a *form*, and leaves out the columns the API excludes.\n // A server-owned column with a declared default is still defaulted here.\n const defaultValue = getDefaultValueFor(property as Property);\n if (result[key] !== undefined) {\n // A map whose own sub-properties carry defaults is filled in\n // field by field, so `{ notify: false }` keeps `notify` and still\n // gains the siblings it did not mention.\n if ((property as Property).type === \"map\" &&\n (property as Property & { defaultValue?: unknown }).defaultValue === undefined &&\n isPlainObject(result[key])) {\n result[key] = {\n ...(defaultValue as Record<string, unknown> ?? {}),\n ...(result[key] as Record<string, unknown>)\n };\n }\n continue;\n }\n if (defaultValue !== undefined) result[key] = defaultValue;\n }\n return result as Partial<EntityValues<M>>;\n}\n\n/** Does this property, or something nested under it, state a `defaultValue`? */\nfunction declaresDefault(property: Property): boolean {\n if (isPropertyBuilder(property)) return false;\n if (property.defaultValue !== undefined) return true;\n if (property.type === \"map\" && property.properties) {\n return Object.values(property.properties as Properties)\n .some(child => child && declaresDefault(child as Property));\n }\n return false;\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * Add missing required fields, expected in the collection, to the values of a entity\n * @param values\n * @param properties\n * @group Driver\n */\nexport function sanitizeData<M extends Record<string, unknown>>\n (\n values: EntityValues<M>,\n properties: Properties\n ) {\n const result = values as Record<string, unknown>;\n Object.entries(properties)\n .forEach(([key, property]) => {\n if (values && values[key] !== undefined) result[key] = values[key];\n else if ((property as Property).validation?.required) result[key] = null;\n });\n return result;\n}\n\n/**\n * A reference to `entity`.\n *\n * @param path where the reference points, when that is not the path the\n * entity is addressed by. A Firestore or MongoDB collection may declare a\n * `path` for its store other than its slug, and a reference has to carry the\n * stored one, where `entity.path` is the path the admin addresses it by.\n */\nexport function getReferenceFrom<M extends Record<string, unknown>>(entity: Entity<M>, path: string = entity.path): EntityReference {\n if (typeof entity.id !== \"string\")\n throw new Error(\"Only string IDs are supported in references\");\n return new EntityReference({\n id: entity.id,\n path,\n driver: entity.driver,\n databaseId: entity.databaseId\n });\n}\n\nexport function getRelationFrom<M extends Record<string, unknown>>(entity: Entity<M>): EntityRelation {\n return new EntityRelation(entity.id, entity.path, entity as unknown as Record<string, unknown>);\n}\n\n/**\n * Normalize a value into a proper EntityRelation instance.\n * Handles EntityRelation class instances, and plain objects\n * with `__type === \"relation\"` or an `isEntityRelation()` method.\n *\n * When `propertyType` is `\"relation\"`, also accepts plain objects that\n * have `id` and `path` fields — these are relation-shaped objects from\n * edge cases in the data pipeline (REST fallback, stale cache, custom data source).\n *\n * When `targetPath` is given, also accepts a bare id. A relation column is a\n * foreign key, and the REST layer returns it as the scalar it is; only some\n * fetch paths hydrate it into an object. Which form a caller sees therefore\n * depends on how the row was loaded, and a caller that only accepted objects\n * reported half of its own data as a type error. The declared target is the\n * missing half: with it, an id is a relation that has not been fetched yet.\n *\n * Returns null if the value cannot be coerced.\n */\nexport function normalizeToEntityRelation(value: unknown, propertyType?: string, targetPath?: string): EntityRelation | null {\n if (value instanceof EntityRelation) return value;\n\n if (targetPath && (typeof value === \"string\" || typeof value === \"number\")) {\n // An empty string is an unset foreign key, not row \"\".\n if (value === \"\") return null;\n return new EntityRelation(value, targetPath);\n }\n\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return null;\n\n const obj = value as Record<string, unknown>;\n const isRelationLike =\n obj.__type === \"relation\" ||\n obj.__type === \"reference\" ||\n (typeof obj.isEntityRelation === \"function\" && (obj.isEntityRelation as () => boolean)()) ||\n (typeof obj.isEntityReference === \"function\" && (obj.isEntityReference as () => boolean)()) ||\n (propertyType === \"relation\" && typeof obj.id !== \"undefined\" && typeof obj.path === \"string\");\n\n if (!isRelationLike) return null;\n\n return new EntityRelation(\n obj.id as string | number,\n obj.path as string,\n obj.data as Record<string, unknown> | undefined\n );\n}\n\nexport function traverseValuesProperties<M extends Record<string, unknown>>(\n inputValues: Partial<EntityValues<M>>,\n properties: Properties,\n operation: (value: unknown, property: Property) => unknown\n): EntityValues<M> | undefined {\n // Handle null/undefined inputValues - use empty object as base for mergeDeep\n const safeInputValues = inputValues ?? {};\n\n const updatedValues = Object.entries(properties)\n .map(([key, property]) => {\n const inputValue = safeInputValues && (safeInputValues)[key];\n const updatedValue = traverseValueProperty(inputValue, property as Property, operation);\n if (updatedValue === null) return null;\n if (updatedValue === undefined) return undefined;\n return ({ [key]: updatedValue });\n })\n .reduce((a, b) => ({ ...a,\n...b }), {}) as EntityValues<M>;\n // Use mergeDeep to preserve class instances like EntityReference, GeoPoint\n const result = mergeDeep(safeInputValues, updatedValues);\n if (!result || Object.keys(result).length === 0) return undefined;\n return result;\n}\n\nexport function traverseValueProperty(inputValue: unknown,\n property: Property,\n operation: (value: unknown, property: Property) => unknown): unknown {\n\n let value;\n if (property.type === \"map\" && property.properties) {\n value = traverseValuesProperties(inputValue as Partial<Record<string, unknown>>, property.properties, operation);\n } else if (property.type === \"array\") {\n const of = property.of;\n if (of && Array.isArray(inputValue) && !Array.isArray(of)) {\n value = inputValue.map((e) => traverseValueProperty(e, of, operation));\n } else if (of && Array.isArray(inputValue) && Array.isArray(of)) {\n value = inputValue.map((e, i) => {\n if (i < of.length)\n return traverseValueProperty(e, of[i], operation);\n return null\n }).filter(Boolean);\n } else if (property.oneOf && Array.isArray(inputValue)) {\n const typeField = property.oneOf?.typeField ?? DEFAULT_ONE_OF_TYPE;\n const valueField = property.oneOf?.valueField ?? DEFAULT_ONE_OF_VALUE;\n value = inputValue.map((e) => {\n if (e === null) return null;\n if (typeof e !== \"object\") return e;\n const rec = e as Record<string, unknown>;\n const type = rec[typeField] as string;\n const childProperty = property.oneOf?.properties[type];\n if (!type || !childProperty) return e;\n return {\n [typeField]: type,\n [valueField]: traverseValueProperty(rec[valueField], childProperty, operation)\n };\n });\n } else {\n value = inputValue;\n }\n } else {\n value = operation(inputValue, property);\n }\n\n return value;\n}\n\n/**\n * Relation reference types used throughout the server layer.\n * These replace the 50+ manual `{ id, path, __type: \"relation\" }` constructions.\n */\nexport interface RelationRef {\n readonly id: string | number;\n readonly path: string;\n readonly __type: \"relation\";\n}\n\nexport interface RelationRefWithData extends RelationRef {\n readonly data: Entity;\n}\n\n/**\n * Create a lightweight relation stub for admin views.\n * Replaces inline `{ id, path, __type: \"relation\" }` object literals.\n */\nexport function createRelationRef(id: string | number, path: string): RelationRef {\n return { id,\npath,\n__type: \"relation\" };\n}\n\n/**\n * A row snapshot with every relation in it reduced to its reference — target\n * path and id, `__type` kept — and the related row's values dropped.\n *\n * What a history entry stores and serves. The history route is gated on\n * reading the row, not the rows it points at, so a snapshot that embedded the\n * admin's `{ __type: \"relation\", data: { values } }` envelope handed whoever\n * may read `posts` history the `authors` columns too. Entries are written as\n * the stored row now, which holds no envelope; this keeps it that way for any\n * path that hands one in, and reads entries recorded before.\n *\n * Walks maps and arrays, since a relation can sit inside either. Anything that\n * is not an envelope comes back as it was.\n */\nexport function withRelationReferencesOnly<T>(value: T): T {\n if (Array.isArray(value)) return value.map(item => withRelationReferencesOnly(item)) as T;\n if (value === null || typeof value !== \"object\" || value instanceof Date) return value;\n const obj = value as Record<string, unknown>;\n if (obj.__type === \"relation\" || obj.__type === \"reference\") {\n const { data: _related, ...reference } = obj;\n return reference as T;\n }\n if (Object.getPrototypeOf(obj) !== Object.prototype && Object.getPrototypeOf(obj) !== null) return value;\n return Object.fromEntries(\n Object.entries(obj).map(([key, item]) => [key, withRelationReferencesOnly(item)])\n ) as T;\n}\n\n/**\n * Create a hydrated relation reference that includes the full entity data.\n * Used when entity data has been pre-fetched (e.g., via batch loading or JOINs).\n */\nexport function createRelationRefWithData(id: string | number, path: string, data: Entity): RelationRefWithData {\n return { id,\npath,\n__type: \"relation\",\ndata };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"entities.es.js","names":[],"sources":["../../src/util/entities.ts"],"sourcesContent":["import {\n DataType,\n Entity,\n EntityReference,\n EntityRelation,\n EntityStatus,\n EntityValues,\n Properties,\n Property\n} from \"@rebasepro/types\";\nimport { DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from \"./common\";\nimport { mergeDeep } from \"@rebasepro/utils\";\n\nexport function isPropertyBuilder(property?: Property) {\n return typeof property?.dynamicProps === \"function\";\n}\n\n/**\n * What a form opens with: a value for every property it can write.\n *\n * `excludeFromApi` columns are left out, and that is the whole of the rule —\n * they are not part of the API surface in either direction, so there is nothing\n * for a form to open showing and nothing it may send back. Including them was\n * not cosmetic: the baseline is what gets submitted, so a new record carried\n * `passwordHash: null` and `emailVerificationToken: null` into the create, and\n * the server refused the whole write with \"these columns are the server's to\n * set\" — the users collection could not be added to from the panel at all. The\n * fields were invisible on screen (`admin.disabled.hidden`), which is what made\n * the error read as being about the roles the operator *had* just edited.\n *\n * Server-side defaulting does not come through here: `applyDefaultValuesOnCreate`\n * asks each property for its own default, so an excluded column with a declared\n * `defaultValue` is still filled in on an in-process write.\n */\nexport function getDefaultValuesFor<M extends Record<string, unknown>>(properties: Properties): Partial<EntityValues<M>> {\n if (!properties) return {};\n return Object.entries(properties)\n .map(([key, property]) => {\n if (!property) return {};\n if ((property as Property).excludeFromApi) return {};\n const value = getDefaultValueFor(property);\n return value === undefined ? {} : { [key]: value };\n })\n .reduce((a, b) => ({ ...a,\n...b }), {}) as EntityValues<M>;\n}\n\nexport function getDefaultValueFor(property?: Property): unknown {\n if (!property) return undefined;\n if (isPropertyBuilder(property)) return undefined;\n // `defaultValue !== undefined`, not truthiness. The test used to be\n // `property.defaultValue || property.defaultValue === null`, which special-\n // cased exactly one falsy value and dropped the rest: `defaultValue: 0`\n // fell through to the per-type default and became `null`, `defaultValue: \"\"`\n // became `null`, and `defaultValue: false` survived only by coincidence\n // (the per-type default for a boolean is also `false`). A default of zero\n // is the most ordinary default a number column has.\n if (property.defaultValue !== undefined) {\n return property.defaultValue;\n } else if (property.type === \"map\" && property.properties) {\n const defaultValuesFor = getDefaultValuesFor(property.properties as Properties);\n if (Object.keys(defaultValuesFor).length === 0) return undefined;\n return defaultValuesFor;\n } else {\n return getDefaultValueFortype(property.type);\n }\n}\n\nexport function getDefaultValueFortype(type: DataType): unknown {\n if (type === \"string\") {\n return null;\n } else if (type === \"number\") {\n return null;\n } else if (type === \"boolean\") {\n return false;\n } else if (type === \"date\") {\n return null;\n } else if (type === \"array\") {\n return [];\n } else if (type === \"map\") {\n return {};\n } else if (type === \"vector\") {\n return null;\n } else if (type === \"binary\") {\n return null;\n } else {\n return null;\n }\n}\n\n/**\n * Update the automatic values in a entity before save\n * @group Driver\n */\nexport function updateDateAutoValues<M extends Record<string, unknown>>({\n inputValues,\n properties,\n status,\n timestampNowValue\n}:\n {\n inputValues: Partial<EntityValues<M>>,\n properties: Properties,\n status: EntityStatus,\n timestampNowValue: unknown\n }): EntityValues<M> {\n return traverseValuesProperties(\n inputValues,\n properties,\n (inputValue, property) => {\n if (property.type === \"date\") {\n if (status === \"existing\" && property.autoValue === \"on_update\") {\n return timestampNowValue;\n } else if ((status === \"new\" || status === \"copy\") &&\n (property.autoValue === \"on_update\" || property.autoValue === \"on_create\")) {\n return timestampNowValue;\n } else {\n return inputValue;\n }\n } else {\n return inputValue;\n }\n }\n ) ?? {} as M;\n}\n\n/**\n * Stamp the acting user's uid into the `user_on_create` / `user_on_update`\n * columns a collection declares.\n *\n * A deliberate sibling of {@link updateDateAutoValues} rather than another\n * branch inside it. The two share a shape and nothing else: one takes an\n * instant the server generates and the other takes an identity the request\n * carries, so overloading the timestamp function would have meant threading a\n * second, unrelated argument through every one of its callers and letting a\n * `date` property and a `string` property compete for the same `autoValue`\n * union. Called side by side in the driver.\n *\n * The stamped value overwrites whatever arrived in the body. A caller who can\n * set `createdBy` is a caller who can attribute their write to somebody else,\n * which is the one thing an audit column must not allow.\n *\n * `uid` is `undefined` for an anonymous request, a service token or an\n * in-process write; the column is set to an explicit `null` there. Explicit\n * matters on an update: leaving the key absent would keep whatever uid the\n * column already held, so an anonymous edit would be recorded as the previous\n * editor's. Refusing that write outright is `required`'s job, not this\n * function's — see `assertWriteValuesValid`.\n *\n * Top-level properties only, deliberately, unlike {@link updateDateAutoValues}.\n * `traverseValuesProperties` cannot express \"set this key to null\" — a `null`\n * from its operation means \"leave the key out\" — and an audit column nested\n * inside a `map` is not a column at all, so there is nothing down there to\n * stamp.\n *\n * @group Driver\n */\nexport function updateUserAutoValues<M extends Record<string, unknown>>({\n inputValues,\n properties,\n status,\n uid\n}:\n {\n inputValues: Partial<EntityValues<M>>,\n properties: Properties,\n status: EntityStatus,\n uid: string | undefined\n }): EntityValues<M> {\n const result = { ...(inputValues ?? {}) } as Record<string, unknown>;\n for (const [key, property] of Object.entries(properties ?? {})) {\n const prop = property as (Property & { autoValue?: string }) | undefined;\n if (!prop || prop.type !== \"string\") continue;\n const autoValue = prop.autoValue;\n if (autoValue !== \"user_on_create\" && autoValue !== \"user_on_update\") continue;\n // `user_on_create` says nothing about an update: the column holds the\n // creator's uid and this write is not rewriting it.\n if (status === \"existing\" && autoValue === \"user_on_create\") continue;\n // A copy is a new row and gets a new author, exactly as it gets a new\n // `created_on`.\n result[key] = uid ?? null;\n }\n return result as EntityValues<M>;\n}\n\n/**\n * Fill in the `defaultValue`s a create left unset.\n *\n * `defaultValue` was read by exactly one thing: the Studio's form, which uses it\n * to prefill inputs. Every other way into the same collection — the REST create,\n * the SDK, the socket, an import — stored whatever arrived and nothing where the\n * key was absent. So `active: { type: \"boolean\", defaultValue: true }` produced\n * rows with `active` unset through the API and `true` through the panel, from\n * one declaration that reads like a promise about the data.\n *\n * Only genuinely absent keys are filled. An explicit `null` is a caller saying\n * \"no value\", which is a different statement from not mentioning the field, and\n * overwriting it would make the default impossible to opt out of. The exception\n * is a `databaseDefault` column, whose `null` is taken out of the create so the\n * database's own DEFAULT fills it.\n *\n * `getDefaultValuesFor` also invents a per-type default for properties with no\n * `defaultValue` at all (`false` for a boolean, `[]` for an array, `null` for\n * the rest) — right for a form, which must render *something* in every input,\n * and wrong here, where an absent key must stay absent so the column's own\n * DEFAULT applies. Only declared defaults are taken.\n *\n * @param values the caller's payload\n * @param properties the collection's declared properties\n * @group Driver\n */\nexport function applyDefaultValuesOnCreate<M extends Record<string, unknown>>(\n values: Partial<EntityValues<M>> | undefined,\n properties: Properties\n): Partial<EntityValues<M>> {\n if (!properties) return values ?? {};\n const result = { ...(values ?? {}) } as Record<string, unknown>;\n\n for (const [key, property] of Object.entries(properties)) {\n if (!property) continue;\n // The database fills this column, and only when the INSERT leaves it\n // out: a `null` (what a form sends for a field nobody filled in) would\n // be stored as NULL, or refused by the column's NOT NULL.\n if (!isPropertyBuilder(property) && (property as Property).databaseDefault && result[key] === null) {\n delete result[key];\n continue;\n }\n const declared = declaresDefault(property as Property);\n if (!declared) continue;\n // Asked of the property, not read out of `getDefaultValuesFor`: that\n // one answers for a *form*, and leaves out the columns the API excludes.\n // A server-owned column with a declared default is still defaulted here.\n const defaultValue = getDefaultValueFor(property as Property);\n if (result[key] !== undefined) {\n // A map whose own sub-properties carry defaults is filled in\n // field by field, so `{ notify: false }` keeps `notify` and still\n // gains the siblings it did not mention.\n if ((property as Property).type === \"map\" &&\n (property as Property & { defaultValue?: unknown }).defaultValue === undefined &&\n isPlainObject(result[key])) {\n result[key] = {\n ...(defaultValue as Record<string, unknown> ?? {}),\n ...(result[key] as Record<string, unknown>)\n };\n }\n continue;\n }\n if (defaultValue !== undefined) result[key] = defaultValue;\n }\n return result as Partial<EntityValues<M>>;\n}\n\n/** Does this property, or something nested under it, state a `defaultValue`? */\nfunction declaresDefault(property: Property): boolean {\n if (isPropertyBuilder(property)) return false;\n if (property.defaultValue !== undefined) return true;\n if (property.type === \"map\" && property.properties) {\n return Object.values(property.properties as Properties)\n .some(child => child && declaresDefault(child as Property));\n }\n return false;\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * Add missing required fields, expected in the collection, to the values of a entity\n * @param values\n * @param properties\n * @group Driver\n */\nexport function sanitizeData<M extends Record<string, unknown>>\n (\n values: EntityValues<M>,\n properties: Properties\n ) {\n const result = values as Record<string, unknown>;\n Object.entries(properties)\n .forEach(([key, property]) => {\n if (values && values[key] !== undefined) result[key] = values[key];\n else if ((property as Property).validation?.required) result[key] = null;\n });\n return result;\n}\n\n/**\n * A reference to `entity`.\n *\n * @param path where the reference points, when that is not the path the\n * entity is addressed by. A Firestore or MongoDB collection may declare a\n * `path` for its store other than its slug, and a reference has to carry the\n * stored one, where `entity.path` is the path the admin addresses it by.\n */\nexport function getReferenceFrom<M extends Record<string, unknown>>(entity: Entity<M>, path: string = entity.path): EntityReference {\n if (typeof entity.id !== \"string\")\n throw new Error(\"Only string IDs are supported in references\");\n return new EntityReference({\n id: entity.id,\n path,\n driver: entity.driver,\n databaseId: entity.databaseId\n });\n}\n\nexport function getRelationFrom<M extends Record<string, unknown>>(entity: Entity<M>): EntityRelation {\n return new EntityRelation(entity.id, entity.path, entity as unknown as Record<string, unknown>);\n}\n\n/**\n * Normalize a value into a proper EntityRelation instance.\n * Handles EntityRelation class instances, and plain objects\n * with `__type === \"relation\"` or an `isEntityRelation()` method.\n *\n * When `propertyType` is `\"relation\"`, also accepts plain objects that\n * have `id` and `path` fields — these are relation-shaped objects from\n * edge cases in the data pipeline (REST fallback, stale cache, custom data source).\n *\n * When `targetPath` is given, also accepts a bare id. A relation column is a\n * foreign key, and the REST layer returns it as the scalar it is; only some\n * fetch paths hydrate it into an object. Which form a caller sees therefore\n * depends on how the row was loaded, and a caller that only accepted objects\n * reported half of its own data as a type error. The declared target is the\n * missing half: with it, an id is a relation that has not been fetched yet.\n *\n * Returns null if the value cannot be coerced.\n */\nexport function normalizeToEntityRelation(value: unknown, propertyType?: string, targetPath?: string): EntityRelation | null {\n if (value instanceof EntityRelation) return value;\n\n if (targetPath && (typeof value === \"string\" || typeof value === \"number\")) {\n // An empty string is an unset foreign key, not row \"\".\n if (value === \"\") return null;\n return new EntityRelation(value, targetPath);\n }\n\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return null;\n\n const obj = value as Record<string, unknown>;\n const isRelationLike =\n obj.__type === \"relation\" ||\n obj.__type === \"reference\" ||\n (typeof obj.isEntityRelation === \"function\" && (obj.isEntityRelation as () => boolean)()) ||\n (typeof obj.isEntityReference === \"function\" && (obj.isEntityReference as () => boolean)()) ||\n (propertyType === \"relation\" && typeof obj.id !== \"undefined\" && typeof obj.path === \"string\");\n\n if (!isRelationLike) return null;\n\n return new EntityRelation(\n obj.id as string | number,\n obj.path as string,\n obj.data as Record<string, unknown> | undefined\n );\n}\n\nexport function traverseValuesProperties<M extends Record<string, unknown>>(\n inputValues: Partial<EntityValues<M>>,\n properties: Properties,\n operation: (value: unknown, property: Property) => unknown\n): EntityValues<M> | undefined {\n // Handle null/undefined inputValues - use empty object as base for mergeDeep\n const safeInputValues = inputValues ?? {};\n\n const updatedValues = Object.entries(properties)\n .map(([key, property]) => {\n const inputValue = safeInputValues && (safeInputValues)[key];\n const updatedValue = traverseValueProperty(inputValue, property as Property, operation);\n if (updatedValue === null) return null;\n if (updatedValue === undefined) return undefined;\n return ({ [key]: updatedValue });\n })\n .reduce((a, b) => ({ ...a,\n...b }), {}) as EntityValues<M>;\n // Use mergeDeep to preserve class instances like EntityReference, GeoPoint\n const result = mergeDeep(safeInputValues, updatedValues);\n if (!result || Object.keys(result).length === 0) return undefined;\n return result;\n}\n\nexport function traverseValueProperty(inputValue: unknown,\n property: Property,\n operation: (value: unknown, property: Property) => unknown): unknown {\n\n let value;\n if (property.type === \"map\" && property.properties) {\n value = traverseValuesProperties(inputValue as Partial<Record<string, unknown>>, property.properties, operation);\n } else if (property.type === \"array\") {\n const of = property.of;\n if (of && Array.isArray(inputValue) && !Array.isArray(of)) {\n value = inputValue.map((e) => traverseValueProperty(e, of, operation));\n } else if (of && Array.isArray(inputValue) && Array.isArray(of)) {\n // A tuple is positional: every element keeps its slot, a `0`, a\n // `false` or a `null` included, and an element past the declared\n // positions has no property to apply and is kept as it is. The\n // driver runs this on every save, so dropping an element here\n // would move every later one into the wrong slot in storage.\n value = inputValue.map((e, i) =>\n i < of.length ? traverseValueProperty(e, of[i], operation) : e);\n } else if (property.oneOf && Array.isArray(inputValue)) {\n const typeField = property.oneOf?.typeField ?? DEFAULT_ONE_OF_TYPE;\n const valueField = property.oneOf?.valueField ?? DEFAULT_ONE_OF_VALUE;\n value = inputValue.map((e) => {\n if (e === null) return null;\n if (typeof e !== \"object\") return e;\n const rec = e as Record<string, unknown>;\n const type = rec[typeField] as string;\n const childProperty = property.oneOf?.properties[type];\n if (!type || !childProperty) return e;\n return {\n [typeField]: type,\n [valueField]: traverseValueProperty(rec[valueField], childProperty, operation)\n };\n });\n } else {\n value = inputValue;\n }\n } else {\n value = operation(inputValue, property);\n }\n\n return value;\n}\n\n/**\n * Relation reference types used throughout the server layer.\n * These replace the 50+ manual `{ id, path, __type: \"relation\" }` constructions.\n */\nexport interface RelationRef {\n readonly id: string | number;\n readonly path: string;\n readonly __type: \"relation\";\n}\n\nexport interface RelationRefWithData extends RelationRef {\n readonly data: Entity;\n}\n\n/**\n * Create a lightweight relation stub for admin views.\n * Replaces inline `{ id, path, __type: \"relation\" }` object literals.\n */\nexport function createRelationRef(id: string | number, path: string): RelationRef {\n return { id,\npath,\n__type: \"relation\" };\n}\n\n/**\n * A row snapshot with every relation in it reduced to its reference — target\n * path and id, `__type` kept — and the related row's values dropped.\n *\n * What a history entry stores and serves. The history route is gated on\n * reading the row, not the rows it points at, so a snapshot that embedded the\n * admin's `{ __type: \"relation\", data: { values } }` envelope handed whoever\n * may read `posts` history the `authors` columns too. Entries are written as\n * the stored row now, which holds no envelope; this keeps it that way for any\n * path that hands one in, and reads entries recorded before.\n *\n * Walks maps and arrays, since a relation can sit inside either. Anything that\n * is not an envelope comes back as it was.\n */\nexport function withRelationReferencesOnly<T>(value: T): T {\n if (Array.isArray(value)) return value.map(item => withRelationReferencesOnly(item)) as T;\n if (value === null || typeof value !== \"object\" || value instanceof Date) return value;\n const obj = value as Record<string, unknown>;\n if (obj.__type === \"relation\" || obj.__type === \"reference\") {\n const { data: _related, ...reference } = obj;\n return reference as T;\n }\n if (Object.getPrototypeOf(obj) !== Object.prototype && Object.getPrototypeOf(obj) !== null) return value;\n return Object.fromEntries(\n Object.entries(obj).map(([key, item]) => [key, withRelationReferencesOnly(item)])\n ) as T;\n}\n\n/**\n * Create a hydrated relation reference that includes the full entity data.\n * Used when entity data has been pre-fetched (e.g., via batch loading or JOINs).\n */\nexport function createRelationRefWithData(id: string | number, path: string, data: Entity): RelationRefWithData {\n return { id,\npath,\n__type: \"relation\",\ndata };\n}\n"],"mappings":"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"}
|
package/dist/util/identity.d.ts
CHANGED
|
@@ -26,6 +26,14 @@ export interface PrimaryKeyInfo {
|
|
|
26
26
|
}
|
|
27
27
|
/** Separator between the parts of a composite address. */
|
|
28
28
|
export declare const COMPOSITE_ID_SEPARATOR = ":::";
|
|
29
|
+
/**
|
|
30
|
+
* Whether `id` names a record.
|
|
31
|
+
*
|
|
32
|
+
* Any number does — `0` is a valid serial or integer key — and any non-empty
|
|
33
|
+
* string. A truthiness test read record `0` as "no record": the panel opened it
|
|
34
|
+
* as a new one, and saving created a row instead of editing it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function hasEntityId(id: string | number | null | undefined): id is string | number;
|
|
29
37
|
/**
|
|
30
38
|
* Whether an address could name a row at all, before asking the database.
|
|
31
39
|
*
|
package/dist/util/identity.es.js
CHANGED
|
@@ -3,13 +3,43 @@
|
|
|
3
3
|
var COMPOSITE_ID_SEPARATOR = ":::";
|
|
4
4
|
/** The eight-four-four-four-twelve shape of a UUID, any version. */
|
|
5
5
|
var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
/** An integer as an address spells it: an optional minus and digits, nothing else. */
|
|
7
|
+
var INTEGER_PATTERN = /^-?\d+$/;
|
|
8
|
+
/**
|
|
9
|
+
* The value of one numeric key part, or `undefined` when the text is not an
|
|
10
|
+
* integer.
|
|
11
|
+
*
|
|
12
|
+
* Whole-string, not `parseInt`: `parseInt` reads the leading digits and stops,
|
|
13
|
+
* so it reads `"12abc"` and `"12.9"` as 12, an address of a row the caller did
|
|
14
|
+
* not name. An integer past what a double holds exactly stays the digits it
|
|
15
|
+
* was written with, because as a number it rounds to a neighbour
|
|
16
|
+
* (`9007199254740993` becomes …992), which is another row; Postgres reads the
|
|
17
|
+
* text into the `bigint` column exactly.
|
|
18
|
+
*/
|
|
19
|
+
function numericKeyPart(part) {
|
|
20
|
+
if (typeof part === "number") return Number.isFinite(part) ? part : void 0;
|
|
21
|
+
if (!INTEGER_PATTERN.test(part)) return void 0;
|
|
22
|
+
const value = Number(part);
|
|
23
|
+
return Number.isSafeInteger(value) ? value : part;
|
|
24
|
+
}
|
|
6
25
|
/** Whether one address part can be a value of the column it addresses. */
|
|
7
26
|
function partIsAddressable(part, pk) {
|
|
8
27
|
if (pk.isUUID) return UUID_PATTERN.test(String(part));
|
|
9
|
-
if (pk.type === "number") return
|
|
28
|
+
if (pk.type === "number") return numericKeyPart(part) !== void 0;
|
|
10
29
|
return true;
|
|
11
30
|
}
|
|
12
31
|
/**
|
|
32
|
+
* Whether `id` names a record.
|
|
33
|
+
*
|
|
34
|
+
* Any number does — `0` is a valid serial or integer key — and any non-empty
|
|
35
|
+
* string. A truthiness test read record `0` as "no record": the panel opened it
|
|
36
|
+
* as a new one, and saving created a row instead of editing it.
|
|
37
|
+
*/
|
|
38
|
+
function hasEntityId(id) {
|
|
39
|
+
if (typeof id === "number") return !Number.isNaN(id);
|
|
40
|
+
return typeof id === "string" && id !== "";
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
13
43
|
* Whether an address could name a row at all, before asking the database.
|
|
14
44
|
*
|
|
15
45
|
* A `uuid` column cannot hold `"new"`, and an `integer` column cannot hold
|
|
@@ -55,8 +85,8 @@ function parseIdValues(idValue, primaryKeys) {
|
|
|
55
85
|
if (primaryKeys.length === 1) {
|
|
56
86
|
const pk = primaryKeys[0];
|
|
57
87
|
if (pk.type === "number" && !pk.isUUID) {
|
|
58
|
-
const parsed =
|
|
59
|
-
if (
|
|
88
|
+
const parsed = numericKeyPart(idValue);
|
|
89
|
+
if (parsed === void 0) throw new Error(`Invalid numeric ID: ${idValue}`);
|
|
60
90
|
result[pk.fieldName] = parsed;
|
|
61
91
|
} else result[pk.fieldName] = String(idValue);
|
|
62
92
|
return result;
|
|
@@ -67,8 +97,8 @@ function parseIdValues(idValue, primaryKeys) {
|
|
|
67
97
|
const pk = primaryKeys[i];
|
|
68
98
|
const val = parts[i];
|
|
69
99
|
if (pk.type === "number" && !pk.isUUID) {
|
|
70
|
-
const parsed =
|
|
71
|
-
if (
|
|
100
|
+
const parsed = numericKeyPart(val);
|
|
101
|
+
if (parsed === void 0) throw new Error(`Invalid numeric ID component: ${val}`);
|
|
72
102
|
result[pk.fieldName] = parsed;
|
|
73
103
|
} else result[pk.fieldName] = val;
|
|
74
104
|
}
|
|
@@ -133,6 +163,6 @@ function resolvePrimaryKeys(collection) {
|
|
|
133
163
|
return [];
|
|
134
164
|
}
|
|
135
165
|
//#endregion
|
|
136
|
-
export { COMPOSITE_ID_SEPARATOR, buildCompositeId, getDeclaredPrimaryKeys, isAddressableId, parseIdValues, resolvePrimaryKeys };
|
|
166
|
+
export { COMPOSITE_ID_SEPARATOR, buildCompositeId, getDeclaredPrimaryKeys, hasEntityId, isAddressableId, parseIdValues, resolvePrimaryKeys };
|
|
137
167
|
|
|
138
168
|
//# sourceMappingURL=identity.es.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"identity.es.js","names":[],"sources":["../../src/util/identity.ts"],"sourcesContent":["/**\n * Row identity: the address of a row, and how to derive it.\n *\n * Postgres has no `id`. A row is identified by its primary key — one or more\n * columns, with any names and any types. `id` is something we synthesize on top\n * of that: a single string token, because the admin needs *one* value it can put\n * in a URL (`/products/1:::2`), use as a cache key, and hang a relation ref off.\n *\n * That token is an address, not data. It is derived from the row's columns and\n * never stored in them — a row is exactly its columns, with their real types.\n * Writing the address back into the row is what used to rename primary keys\n * (`sku` → `id`) and restringify them (`42` → `\"42\"`) on the way out.\n *\n * These live in `common` because both sides need them and must agree exactly:\n * the driver parses an incoming address back into key columns, and the admin\n * derives the address from a row it was served.\n */\n\n/**\n * A primary-key column: its name, the type it round-trips as, and whether it is\n * a UUID (which is a string despite sometimes being described as an id \"number\").\n */\nexport interface PrimaryKeyInfo {\n fieldName: string;\n type: \"string\" | \"number\";\n isUUID?: boolean;\n}\n\n/** Separator between the parts of a composite address. */\nexport const COMPOSITE_ID_SEPARATOR = \":::\";\n\n/** The eight-four-four-four-twelve shape of a UUID, any version. */\nconst UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/** Whether one address part can be a value of the column it addresses. */\nfunction partIsAddressable(part: string | number, pk: PrimaryKeyInfo): boolean {\n if (pk.isUUID) return UUID_PATTERN.test(String(part));\n if (pk.type === \"number\") {\n return typeof part === \"number\"\n ? Number.isFinite(part)\n : !isNaN(parseInt(String(part), 10));\n }\n return true;\n}\n\n/**\n * Whether an address could name a row at all, before asking the database.\n *\n * A `uuid` column cannot hold `\"new\"`, and an `integer` column cannot hold\n * `\"abc\"` — so the answer to \"which row is this\" is \"none\", and that is a 404,\n * not a failure. Postgres cannot say so politely: the comparison never runs, it\n * raises `22P02` and aborts the enclosing transaction, after which every\n * further statement returns the far less helpful `25P02`.\n *\n * `isUUID` must come from the column, not from `isId: \"uuid\"` in a config: the\n * config is a claim about a key, and a `text` column that holds ids of some\n * other shape is a working app this must not start rejecting.\n */\nexport function isAddressableId(idValue: string | number, primaryKeys: PrimaryKeyInfo[]): boolean {\n if (primaryKeys.length === 0) return false;\n if (primaryKeys.length === 1) return partIsAddressable(idValue, primaryKeys[0]);\n\n const parts = String(idValue).split(COMPOSITE_ID_SEPARATOR);\n if (parts.length !== primaryKeys.length) return false;\n return parts.every((part, i) => partIsAddressable(part, primaryKeys[i]));\n}\n\n/**\n * Derive a row's address from its key columns.\n *\n * Single key → the value as a string. Composite → each part joined by\n * {@link COMPOSITE_ID_SEPARATOR}, in primary-key order, which is what\n * {@link parseIdValues} expects to invert.\n */\nexport function buildCompositeId(values: Record<string, unknown>, primaryKeys: PrimaryKeyInfo[]): string {\n if (primaryKeys.length === 0) {\n return \"\";\n }\n if (primaryKeys.length === 1) {\n return String(values[primaryKeys[0].fieldName] ?? \"\");\n }\n return primaryKeys.map(pk => String(values[pk.fieldName] ?? \"\")).join(COMPOSITE_ID_SEPARATOR);\n}\n\n/**\n * Invert {@link buildCompositeId}: turn an address back into key columns, each\n * coerced to the type its column actually round-trips as.\n *\n * This is the boundary where a URL segment becomes a query parameter, so a\n * malformed address must throw rather than silently produce a query that\n * matches the wrong row (or none).\n */\nexport function parseIdValues(idValue: string | number, primaryKeys: PrimaryKeyInfo[]): Record<string, string | number> {\n const result: Record<string, string | number> = {};\n\n if (primaryKeys.length === 0) {\n return result;\n }\n\n if (primaryKeys.length === 1) {\n const pk = primaryKeys[0];\n if (pk.type === \"number\" && !pk.isUUID) {\n const parsed = typeof idValue === \"number\" ? idValue : parseInt(String(idValue), 10);\n if (isNaN(parsed)) {\n throw new Error(`Invalid numeric ID: ${idValue}`);\n }\n result[pk.fieldName] = parsed;\n } else {\n result[pk.fieldName] = String(idValue);\n }\n return result;\n }\n\n // Composite key\n const parts = String(idValue).split(COMPOSITE_ID_SEPARATOR);\n if (parts.length !== primaryKeys.length) {\n throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);\n }\n\n for (let i = 0; i < primaryKeys.length; i++) {\n const pk = primaryKeys[i];\n const val = parts[i];\n if (pk.type === \"number\" && !pk.isUUID) {\n const parsed = parseInt(val, 10);\n if (isNaN(parsed)) {\n throw new Error(`Invalid numeric ID component: ${val}`);\n }\n result[pk.fieldName] = parsed;\n } else {\n result[pk.fieldName] = val;\n }\n }\n\n return result;\n}\n\n/**\n * The primary keys of a collection, as declared by its properties.\n *\n * This is the only tier both sides can read, because it is the only one written\n * in the config: the postgres driver can also infer keys from the Drizzle\n * schema, which the browser never sees and is never sent — the admin compiles\n * the collection files into its own bundle rather than being served them. A key\n * that lives only in the Drizzle schema is therefore invisible here, and the\n * server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`\n * to add.\n *\n * Returns an empty array when a collection declares none, which callers must\n * treat as \"not addressable\" rather than defaulting to `id`: guessing a key\n * that is not the real one produces confidently wrong addresses.\n */\nexport function getDeclaredPrimaryKeys(collection: {\n properties?: Record<string, unknown>;\n}): PrimaryKeyInfo[] {\n const properties = collection.properties;\n if (!properties) return [];\n\n const keys: PrimaryKeyInfo[] = [];\n for (const [fieldName, propRaw] of Object.entries(properties)) {\n const prop = propRaw as { type?: string; isId?: unknown } | undefined;\n if (!prop || typeof prop !== \"object\") continue;\n if (!(\"isId\" in prop) || !prop.isId) continue;\n keys.push({\n fieldName,\n type: prop.type === \"number\" ? \"number\" : \"string\",\n isUUID: prop.isId === \"uuid\"\n });\n }\n return keys;\n}\n\n/**\n * The keys to address a collection's rows with, resolved the way the driver\n * resolves them — minus the tier the browser cannot reach.\n *\n * The postgres driver tries, in order: properties marked `isId`; the primary\n * keys of the Drizzle schema; and finally a column literally named `id`. Only\n * the first and last are visible in a `CollectionConfig`, which is what both\n * sides share.\n *\n * So the two agree except on a collection that declares no `isId` and whose key\n * is known only to Drizzle. There, the driver reads the real key, and this\n * either resolves nothing (reported to the console by the caller) or — if the\n * table happens to have an unrelated `id` property — resolves `id`, which is\n * the wrong key and cannot be detected from here: the addresses look right and\n * route wrong. Only the config can settle it, so the server names both cases\n * at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.\n */\nexport function resolvePrimaryKeys(collection: {\n properties?: Record<string, unknown>;\n}): PrimaryKeyInfo[] {\n const declared = getDeclaredPrimaryKeys(collection);\n if (declared.length > 0) return declared;\n\n const idProp = collection.properties?.id as { type?: string } | undefined;\n if (idProp && typeof idProp === \"object\") {\n return [{ fieldName: \"id\",\ntype: idProp.type === \"number\" ? \"number\" : \"string\" }];\n }\n\n return [];\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"identity.es.js","names":[],"sources":["../../src/util/identity.ts"],"sourcesContent":["/**\n * Row identity: the address of a row, and how to derive it.\n *\n * Postgres has no `id`. A row is identified by its primary key — one or more\n * columns, with any names and any types. `id` is something we synthesize on top\n * of that: a single string token, because the admin needs *one* value it can put\n * in a URL (`/products/1:::2`), use as a cache key, and hang a relation ref off.\n *\n * That token is an address, not data. It is derived from the row's columns and\n * never stored in them — a row is exactly its columns, with their real types.\n * Writing the address back into the row is what used to rename primary keys\n * (`sku` → `id`) and restringify them (`42` → `\"42\"`) on the way out.\n *\n * These live in `common` because both sides need them and must agree exactly:\n * the driver parses an incoming address back into key columns, and the admin\n * derives the address from a row it was served.\n */\n\n/**\n * A primary-key column: its name, the type it round-trips as, and whether it is\n * a UUID (which is a string despite sometimes being described as an id \"number\").\n */\nexport interface PrimaryKeyInfo {\n fieldName: string;\n type: \"string\" | \"number\";\n isUUID?: boolean;\n}\n\n/** Separator between the parts of a composite address. */\nexport const COMPOSITE_ID_SEPARATOR = \":::\";\n\n/** The eight-four-four-four-twelve shape of a UUID, any version. */\nconst UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/** An integer as an address spells it: an optional minus and digits, nothing else. */\nconst INTEGER_PATTERN = /^-?\\d+$/;\n\n/**\n * The value of one numeric key part, or `undefined` when the text is not an\n * integer.\n *\n * Whole-string, not `parseInt`: `parseInt` reads the leading digits and stops,\n * so it reads `\"12abc\"` and `\"12.9\"` as 12, an address of a row the caller did\n * not name. An integer past what a double holds exactly stays the digits it\n * was written with, because as a number it rounds to a neighbour\n * (`9007199254740993` becomes …992), which is another row; Postgres reads the\n * text into the `bigint` column exactly.\n */\nfunction numericKeyPart(part: string | number): string | number | undefined {\n if (typeof part === \"number\") return Number.isFinite(part) ? part : undefined;\n if (!INTEGER_PATTERN.test(part)) return undefined;\n const value = Number(part);\n return Number.isSafeInteger(value) ? value : part;\n}\n\n/** Whether one address part can be a value of the column it addresses. */\nfunction partIsAddressable(part: string | number, pk: PrimaryKeyInfo): boolean {\n if (pk.isUUID) return UUID_PATTERN.test(String(part));\n if (pk.type === \"number\") return numericKeyPart(part) !== undefined;\n return true;\n}\n\n/**\n * Whether `id` names a record.\n *\n * Any number does — `0` is a valid serial or integer key — and any non-empty\n * string. A truthiness test read record `0` as \"no record\": the panel opened it\n * as a new one, and saving created a row instead of editing it.\n */\nexport function hasEntityId(id: string | number | null | undefined): id is string | number {\n if (typeof id === \"number\") return !Number.isNaN(id);\n return typeof id === \"string\" && id !== \"\";\n}\n\n/**\n * Whether an address could name a row at all, before asking the database.\n *\n * A `uuid` column cannot hold `\"new\"`, and an `integer` column cannot hold\n * `\"abc\"` — so the answer to \"which row is this\" is \"none\", and that is a 404,\n * not a failure. Postgres cannot say so politely: the comparison never runs, it\n * raises `22P02` and aborts the enclosing transaction, after which every\n * further statement returns the far less helpful `25P02`.\n *\n * `isUUID` must come from the column, not from `isId: \"uuid\"` in a config: the\n * config is a claim about a key, and a `text` column that holds ids of some\n * other shape is a working app this must not start rejecting.\n */\nexport function isAddressableId(idValue: string | number, primaryKeys: PrimaryKeyInfo[]): boolean {\n if (primaryKeys.length === 0) return false;\n if (primaryKeys.length === 1) return partIsAddressable(idValue, primaryKeys[0]);\n\n const parts = String(idValue).split(COMPOSITE_ID_SEPARATOR);\n if (parts.length !== primaryKeys.length) return false;\n return parts.every((part, i) => partIsAddressable(part, primaryKeys[i]));\n}\n\n/**\n * Derive a row's address from its key columns.\n *\n * Single key → the value as a string. Composite → each part joined by\n * {@link COMPOSITE_ID_SEPARATOR}, in primary-key order, which is what\n * {@link parseIdValues} expects to invert.\n */\nexport function buildCompositeId(values: Record<string, unknown>, primaryKeys: PrimaryKeyInfo[]): string {\n if (primaryKeys.length === 0) {\n return \"\";\n }\n if (primaryKeys.length === 1) {\n return String(values[primaryKeys[0].fieldName] ?? \"\");\n }\n return primaryKeys.map(pk => String(values[pk.fieldName] ?? \"\")).join(COMPOSITE_ID_SEPARATOR);\n}\n\n/**\n * Invert {@link buildCompositeId}: turn an address back into key columns, each\n * coerced to the type its column actually round-trips as.\n *\n * This is the boundary where a URL segment becomes a query parameter, so a\n * malformed address must throw rather than silently produce a query that\n * matches the wrong row (or none).\n */\nexport function parseIdValues(idValue: string | number, primaryKeys: PrimaryKeyInfo[]): Record<string, string | number> {\n const result: Record<string, string | number> = {};\n\n if (primaryKeys.length === 0) {\n return result;\n }\n\n if (primaryKeys.length === 1) {\n const pk = primaryKeys[0];\n if (pk.type === \"number\" && !pk.isUUID) {\n const parsed = numericKeyPart(idValue);\n if (parsed === undefined) {\n throw new Error(`Invalid numeric ID: ${idValue}`);\n }\n result[pk.fieldName] = parsed;\n } else {\n result[pk.fieldName] = String(idValue);\n }\n return result;\n }\n\n // Composite key\n const parts = String(idValue).split(COMPOSITE_ID_SEPARATOR);\n if (parts.length !== primaryKeys.length) {\n throw new Error(`Composite ID parts mismatch. Expected ${primaryKeys.length}, got ${parts.length} for ID: ${idValue}`);\n }\n\n for (let i = 0; i < primaryKeys.length; i++) {\n const pk = primaryKeys[i];\n const val = parts[i];\n if (pk.type === \"number\" && !pk.isUUID) {\n const parsed = numericKeyPart(val);\n if (parsed === undefined) {\n throw new Error(`Invalid numeric ID component: ${val}`);\n }\n result[pk.fieldName] = parsed;\n } else {\n result[pk.fieldName] = val;\n }\n }\n\n return result;\n}\n\n/**\n * The primary keys of a collection, as declared by its properties.\n *\n * This is the only tier both sides can read, because it is the only one written\n * in the config: the postgres driver can also infer keys from the Drizzle\n * schema, which the browser never sees and is never sent — the admin compiles\n * the collection files into its own bundle rather than being served them. A key\n * that lives only in the Drizzle schema is therefore invisible here, and the\n * server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`\n * to add.\n *\n * Returns an empty array when a collection declares none, which callers must\n * treat as \"not addressable\" rather than defaulting to `id`: guessing a key\n * that is not the real one produces confidently wrong addresses.\n */\nexport function getDeclaredPrimaryKeys(collection: {\n properties?: Record<string, unknown>;\n}): PrimaryKeyInfo[] {\n const properties = collection.properties;\n if (!properties) return [];\n\n const keys: PrimaryKeyInfo[] = [];\n for (const [fieldName, propRaw] of Object.entries(properties)) {\n const prop = propRaw as { type?: string; isId?: unknown } | undefined;\n if (!prop || typeof prop !== \"object\") continue;\n if (!(\"isId\" in prop) || !prop.isId) continue;\n keys.push({\n fieldName,\n type: prop.type === \"number\" ? \"number\" : \"string\",\n isUUID: prop.isId === \"uuid\"\n });\n }\n return keys;\n}\n\n/**\n * The keys to address a collection's rows with, resolved the way the driver\n * resolves them — minus the tier the browser cannot reach.\n *\n * The postgres driver tries, in order: properties marked `isId`; the primary\n * keys of the Drizzle schema; and finally a column literally named `id`. Only\n * the first and last are visible in a `CollectionConfig`, which is what both\n * sides share.\n *\n * So the two agree except on a collection that declares no `isId` and whose key\n * is known only to Drizzle. There, the driver reads the real key, and this\n * either resolves nothing (reported to the console by the caller) or — if the\n * table happens to have an unrelated `id` property — resolves `id`, which is\n * the wrong key and cannot be detected from here: the addresses look right and\n * route wrong. Only the config can settle it, so the server names both cases\n * at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.\n */\nexport function resolvePrimaryKeys(collection: {\n properties?: Record<string, unknown>;\n}): PrimaryKeyInfo[] {\n const declared = getDeclaredPrimaryKeys(collection);\n if (declared.length > 0) return declared;\n\n const idProp = collection.properties?.id as { type?: string } | undefined;\n if (idProp && typeof idProp === \"object\") {\n return [{ fieldName: \"id\",\ntype: idProp.type === \"number\" ? \"number\" : \"string\" }];\n }\n\n return [];\n}\n"],"mappings":"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"}
|
package/dist/util/index.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export * from "./string-column-length.js";
|
|
|
27
27
|
export * from "./internal-tables.js";
|
|
28
28
|
export * from "./sql-rows.js";
|
|
29
29
|
export * from "./sql-redaction.js";
|
|
30
|
+
export * from "./table-ddl-warning.js";
|
|
30
31
|
export * from "./copy.js";
|
|
31
32
|
export * from "./untrusted-envelope.js";
|
|
32
33
|
export * from "./deprecation.js";
|
|
@@ -75,7 +75,7 @@ function evaluateCompare(op, left, right, ctx) {
|
|
|
75
75
|
if (!l.known || !r.known) return "unknown";
|
|
76
76
|
const a = l.value;
|
|
77
77
|
const b = r.value;
|
|
78
|
-
if (a === null || b === null) return "unknown";
|
|
78
|
+
if (a === null || a === void 0 || b === null || b === void 0) return "unknown";
|
|
79
79
|
if (op === "eq") return a === b;
|
|
80
80
|
if (op === "neq") return a !== b;
|
|
81
81
|
if (typeof a === "string" && typeof b === "string") {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"evaluatePolicy.es.js","names":[],"sources":["../../../src/util/policy/evaluatePolicy.ts"],"sourcesContent":["import { ANONYMOUS_USER_ID, isAnonymousUid, Entity, PolicyCompareOperator, PolicyExpression, PolicyOperand } from \"@rebasepro/types\";\n\n/**\n * Result of evaluating a policy client-side. `\"unknown\"` means the expression\n * could not be decided without more information — either a raw-SQL escape-hatch\n * node (which the client deliberately never guesses) or a row-column reference\n * with no entity in hand (e.g. list-level gating). Callers decide how to resolve\n * `\"unknown\"`: fail-closed for an enforcement decision, optimistic for pure\n * visibility gating.\n */\nexport type TriState = boolean | \"unknown\";\n\n/**\n * Context for {@link evaluatePolicy}: the acting user (or none) and the row\n * being evaluated (or none, for collection-level gating).\n */\nexport interface PolicyEvalContext {\n /**\n * The current user's id, or null/undefined when no user is signed in.\n *\n * Null here means *anonymous visitor*, not \"server context\" — a client is\n * never the server context. `authUid` operands therefore resolve to\n * {@link ANONYMOUS_USER_ID} rather than `null`, matching the `rebase.uid()`\n * the database would see for the same request.\n */\n uid?: string | null;\n /** The current user's application roles. */\n roles?: string[];\n /**\n * Whether this session is a GUEST — anonymous sign-in rather than an\n * account. Optional, and absent means \"not a guest\", so a caller that does\n * not know keeps the behaviour it had.\n */\n isAnonymous?: boolean;\n /** The row being evaluated, or null when no specific row is available. */\n entity: Entity | null;\n}\n\n/**\n * Evaluates a {@link PolicyExpression} against a user + row, using three-valued\n * (Kleene) logic so that `\"unknown\"` sub-results propagate soundly.\n *\n * This is the JavaScript twin of {@link policyToPostgres}: both derive from the\n * same expression, so the admin UI matches database enforcement by construction\n * for every non-raw rule.\n */\nexport function evaluatePolicy(expr: PolicyExpression, ctx: PolicyEvalContext): TriState {\n switch (expr.kind) {\n case \"true\":\n return true;\n case \"false\":\n return false;\n case \"and\":\n return kleeneAnd(expr.operands.map(o => evaluatePolicy(o, ctx)));\n case \"or\":\n return kleeneOr(expr.operands.map(o => evaluatePolicy(o, ctx)));\n case \"not\":\n return kleeneNot(evaluatePolicy(expr.operand, ctx));\n case \"compare\":\n return evaluateCompare(expr.op, expr.left, expr.right, ctx);\n case \"rolesOverlap\": {\n const userRoles = ctx.roles ?? [];\n return expr.roles.some(r => r === \"public\" || userRoles.includes(r));\n }\n case \"rolesContain\": {\n const userRoles = ctx.roles ?? [];\n return expr.roles.every(r => r === \"public\" || userRoles.includes(r));\n }\n case \"authenticated\":\n // Every anonymous spelling, matching what this node compiles to in\n // Postgres — the two evaluators disagreeing about who is signed in\n // is the client optimistically rendering a row the database will\n // refuse, or hiding one it would have allowed.\n return ctx.uid != null && !isAnonymousUid(ctx.uid);\n case \"registered\":\n // The same two halves the Postgres compilation has. A client that\n // disagreed with the database here would optimistically render a\n // row the database refuses, or hide one it would have allowed.\n return ctx.uid != null && !isAnonymousUid(ctx.uid) && ctx.isAnonymous !== true;\n case \"serverContext\":\n // A client is never the server context. Postgres decides this by\n // `rebase.uid() IS NULL`, which a client request can never produce:\n // the driver substitutes ANONYMOUS_USER_ID for a missing id.\n return false;\n case \"existsIn\":\n // A membership subquery cannot be run client-side — server-authoritative.\n return \"unknown\";\n case \"raw\":\n // Arbitrary SQL cannot be evaluated client-side — never guess.\n return \"unknown\";\n }\n}\n\n// ── Three-valued logic ───────────────────────────────────────────────\n\nfunction kleeneAnd(values: TriState[]): TriState {\n if (values.some(v => v === false)) return false;\n if (values.some(v => v === \"unknown\")) return \"unknown\";\n return true;\n}\n\nfunction kleeneOr(values: TriState[]): TriState {\n if (values.some(v => v === true)) return true;\n if (values.some(v => v === \"unknown\")) return \"unknown\";\n return false;\n}\n\nfunction kleeneNot(value: TriState): TriState {\n if (value === \"unknown\") return \"unknown\";\n return !value;\n}\n\n// ── Comparison ───────────────────────────────────────────────────────\n\ntype ResolvedOperand = { known: false } | { known: true; value: unknown };\n\nfunction resolveOperand(operand: PolicyOperand, ctx: PolicyEvalContext): ResolvedOperand {\n switch (operand.kind) {\n case \"literal\":\n return { known: true, value: operand.value };\n case \"authUid\":\n // The sentinel, not null: `rebase.uid()` is never NULL for a request\n // that came from a client, so comparing against null here would\n // disagree with the database on exactly the rules that test for it\n // (e.g. `rebase.uid() <> 'anonymous'`).\n return { known: true, value: ctx.uid ?? ANONYMOUS_USER_ID };\n case \"authRoles\":\n return { known: true, value: ctx.roles ?? [] };\n case \"field\":\n // Can't resolve a row column without the row.\n if (!ctx.entity) return { known: false };\n return { known: true, value: ctx.entity.values[operand.name] };\n case \"outerField\":\n // Only meaningful inside an `existsIn` subquery (server-authoritative).\n return { known: false };\n case \"authClaim\":\n // Server-authoritative, like `existsIn` and `raw`, and for a reason\n // worth stating: a claim is TEXT, and Postgres compares it to the\n // column after casting it to the column's type. Reproducing that\n // here means reproducing uuid case-folding, numeric widening and\n // the `NULLIF`, in JavaScript, from a `PolicyEvalContext` that does\n // not know the column's type. A second implementation of a cast\n // that is subtly wrong is worse than no answer: it would render a\n // row the database refuses, or hide one it would have allowed.\n return { known: false };\n }\n}\n\nfunction evaluateCompare(\n op: PolicyCompareOperator,\n left: PolicyOperand,\n right: PolicyOperand,\n ctx: PolicyEvalContext\n): TriState {\n const l = resolveOperand(left, ctx);\n const r = resolveOperand(right, ctx);\n if (!l.known || !r.known) return \"unknown\";\n\n const a = l.value;\n const b = r.value;\n\n if (a === null || b === null) {\n // SQL answers NULL for *every* comparison against NULL, and a policy\n // that answers NULL does not grant the row. So the only question here\n // is which JavaScript answer reproduces that outcome.\n //\n // This used to answer `false` for `eq` and `true` for `neq`, which is\n // JavaScript's two-valued reading of a three-valued question.\n //\n // `neq` was a grant the database does not give:\n // `owner_id != rebase.uid()` on a row whose `owner_id` is NULL read as\n // *permitted* in the admin panel and was refused by Postgres — on every\n // row where the column is null, which for a nullable column is usually\n // most of them.\n //\n // `false` for `eq` looked safe, because false denies and NULL denies.\n // It is not, because it does not survive negation: `not(a = NULL)`\n // became `true` while `NOT NULL` stays NULL, so the same grant reappears\n // one operator up. A local answer that is only right in a positive\n // position is not right — it just moves.\n //\n // \"unknown\" is what SQL actually says, it composes correctly through\n // Kleene negation, and enforcement callers already resolve it\n // fail-closed. Both were found by the exhaustive Postgres differential\n // in `policy-agreement-exhaustive.test.ts`, the second only after the\n // first was fixed.\n return \"unknown\";\n }\n\n if (op === \"eq\") return a === b;\n if (op === \"neq\") return a !== b;\n\n if (typeof a === \"string\" && typeof b === \"string\") {\n if (op === \"lt\") return a < b;\n if (op === \"lte\") return a <= b;\n if (op === \"gt\") return a > b;\n if (op === \"gte\") return a >= b;\n }\n\n if (typeof a === \"number\" && typeof b === \"number\") {\n if (op === \"lt\") return a < b;\n if (op === \"lte\") return a <= b;\n if (op === \"gt\") return a > b;\n if (op === \"gte\") return a >= b;\n }\n\n if (typeof a === \"bigint\" && typeof b === \"bigint\") {\n if (op === \"lt\") return a < b;\n if (op === \"lte\") return a <= b;\n if (op === \"gt\") return a > b;\n if (op === \"gte\") return a >= b;\n }\n\n return \"unknown\";\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"evaluatePolicy.es.js","names":[],"sources":["../../../src/util/policy/evaluatePolicy.ts"],"sourcesContent":["import { ANONYMOUS_USER_ID, isAnonymousUid, Entity, PolicyCompareOperator, PolicyExpression, PolicyOperand } from \"@rebasepro/types\";\n\n/**\n * Result of evaluating a policy client-side. `\"unknown\"` means the expression\n * could not be decided without more information — either a raw-SQL escape-hatch\n * node (which the client deliberately never guesses) or a row-column reference\n * with no entity in hand (e.g. list-level gating). Callers decide how to resolve\n * `\"unknown\"`: fail-closed for an enforcement decision, optimistic for pure\n * visibility gating.\n */\nexport type TriState = boolean | \"unknown\";\n\n/**\n * Context for {@link evaluatePolicy}: the acting user (or none) and the row\n * being evaluated (or none, for collection-level gating).\n */\nexport interface PolicyEvalContext {\n /**\n * The current user's id, or null/undefined when no user is signed in.\n *\n * Null here means *anonymous visitor*, not \"server context\" — a client is\n * never the server context. `authUid` operands therefore resolve to\n * {@link ANONYMOUS_USER_ID} rather than `null`, matching the `rebase.uid()`\n * the database would see for the same request.\n */\n uid?: string | null;\n /** The current user's application roles. */\n roles?: string[];\n /**\n * Whether this session is a GUEST — anonymous sign-in rather than an\n * account. Optional, and absent means \"not a guest\", so a caller that does\n * not know keeps the behaviour it had.\n */\n isAnonymous?: boolean;\n /** The row being evaluated, or null when no specific row is available. */\n entity: Entity | null;\n}\n\n/**\n * Evaluates a {@link PolicyExpression} against a user + row, using three-valued\n * (Kleene) logic so that `\"unknown\"` sub-results propagate soundly.\n *\n * This is the JavaScript twin of {@link policyToPostgres}: both derive from the\n * same expression, so the admin UI matches database enforcement by construction\n * for every non-raw rule.\n */\nexport function evaluatePolicy(expr: PolicyExpression, ctx: PolicyEvalContext): TriState {\n switch (expr.kind) {\n case \"true\":\n return true;\n case \"false\":\n return false;\n case \"and\":\n return kleeneAnd(expr.operands.map(o => evaluatePolicy(o, ctx)));\n case \"or\":\n return kleeneOr(expr.operands.map(o => evaluatePolicy(o, ctx)));\n case \"not\":\n return kleeneNot(evaluatePolicy(expr.operand, ctx));\n case \"compare\":\n return evaluateCompare(expr.op, expr.left, expr.right, ctx);\n case \"rolesOverlap\": {\n const userRoles = ctx.roles ?? [];\n return expr.roles.some(r => r === \"public\" || userRoles.includes(r));\n }\n case \"rolesContain\": {\n const userRoles = ctx.roles ?? [];\n return expr.roles.every(r => r === \"public\" || userRoles.includes(r));\n }\n case \"authenticated\":\n // Every anonymous spelling, matching what this node compiles to in\n // Postgres — the two evaluators disagreeing about who is signed in\n // is the client optimistically rendering a row the database will\n // refuse, or hiding one it would have allowed.\n return ctx.uid != null && !isAnonymousUid(ctx.uid);\n case \"registered\":\n // The same two halves the Postgres compilation has. A client that\n // disagreed with the database here would optimistically render a\n // row the database refuses, or hide one it would have allowed.\n return ctx.uid != null && !isAnonymousUid(ctx.uid) && ctx.isAnonymous !== true;\n case \"serverContext\":\n // A client is never the server context. Postgres decides this by\n // `rebase.uid() IS NULL`, which a client request can never produce:\n // the driver substitutes ANONYMOUS_USER_ID for a missing id.\n return false;\n case \"existsIn\":\n // A membership subquery cannot be run client-side — server-authoritative.\n return \"unknown\";\n case \"raw\":\n // Arbitrary SQL cannot be evaluated client-side — never guess.\n return \"unknown\";\n }\n}\n\n// ── Three-valued logic ───────────────────────────────────────────────\n\nfunction kleeneAnd(values: TriState[]): TriState {\n if (values.some(v => v === false)) return false;\n if (values.some(v => v === \"unknown\")) return \"unknown\";\n return true;\n}\n\nfunction kleeneOr(values: TriState[]): TriState {\n if (values.some(v => v === true)) return true;\n if (values.some(v => v === \"unknown\")) return \"unknown\";\n return false;\n}\n\nfunction kleeneNot(value: TriState): TriState {\n if (value === \"unknown\") return \"unknown\";\n return !value;\n}\n\n// ── Comparison ───────────────────────────────────────────────────────\n\ntype ResolvedOperand = { known: false } | { known: true; value: unknown };\n\nfunction resolveOperand(operand: PolicyOperand, ctx: PolicyEvalContext): ResolvedOperand {\n switch (operand.kind) {\n case \"literal\":\n return { known: true, value: operand.value };\n case \"authUid\":\n // The sentinel, not null: `rebase.uid()` is never NULL for a request\n // that came from a client, so comparing against null here would\n // disagree with the database on exactly the rules that test for it\n // (e.g. `rebase.uid() <> 'anonymous'`).\n return { known: true, value: ctx.uid ?? ANONYMOUS_USER_ID };\n case \"authRoles\":\n return { known: true, value: ctx.roles ?? [] };\n case \"field\":\n // Can't resolve a row column without the row.\n if (!ctx.entity) return { known: false };\n return { known: true, value: ctx.entity.values[operand.name] };\n case \"outerField\":\n // Only meaningful inside an `existsIn` subquery (server-authoritative).\n return { known: false };\n case \"authClaim\":\n // Server-authoritative, like `existsIn` and `raw`, and for a reason\n // worth stating: a claim is TEXT, and Postgres compares it to the\n // column after casting it to the column's type. Reproducing that\n // here means reproducing uuid case-folding, numeric widening and\n // the `NULLIF`, in JavaScript, from a `PolicyEvalContext` that does\n // not know the column's type. A second implementation of a cast\n // that is subtly wrong is worse than no answer: it would render a\n // row the database refuses, or hide one it would have allowed.\n return { known: false };\n }\n}\n\nfunction evaluateCompare(\n op: PolicyCompareOperator,\n left: PolicyOperand,\n right: PolicyOperand,\n ctx: PolicyEvalContext\n): TriState {\n const l = resolveOperand(left, ctx);\n const r = resolveOperand(right, ctx);\n if (!l.known || !r.known) return \"unknown\";\n\n const a = l.value;\n const b = r.value;\n\n // An absent value is NULL too. A document that never had the field — a\n // MongoDB row, a create that did not mention it — reads `undefined` here,\n // and the column it stands for is NULL. Both spellings of \"no value\" get\n // the same answer, so `owner != uid` grants a row with no owner neither\n // when the owner is null nor when it is missing, and two missing fields\n // never compare equal.\n if (a === null || a === undefined || b === null || b === undefined) {\n // SQL answers NULL for *every* comparison against NULL, and a policy\n // that answers NULL does not grant the row. So the only question here\n // is which JavaScript answer reproduces that outcome.\n //\n // This used to answer `false` for `eq` and `true` for `neq`, which is\n // JavaScript's two-valued reading of a three-valued question.\n //\n // `neq` was a grant the database does not give:\n // `owner_id != rebase.uid()` on a row whose `owner_id` is NULL read as\n // *permitted* in the admin panel and was refused by Postgres — on every\n // row where the column is null, which for a nullable column is usually\n // most of them.\n //\n // `false` for `eq` looked safe, because false denies and NULL denies.\n // It is not, because it does not survive negation: `not(a = NULL)`\n // became `true` while `NOT NULL` stays NULL, so the same grant reappears\n // one operator up. A local answer that is only right in a positive\n // position is not right — it just moves.\n //\n // \"unknown\" is what SQL actually says, it composes correctly through\n // Kleene negation, and enforcement callers already resolve it\n // fail-closed. Both were found by the exhaustive Postgres differential\n // in `policy-agreement-exhaustive.test.ts`, the second only after the\n // first was fixed.\n return \"unknown\";\n }\n\n if (op === \"eq\") return a === b;\n if (op === \"neq\") return a !== b;\n\n if (typeof a === \"string\" && typeof b === \"string\") {\n if (op === \"lt\") return a < b;\n if (op === \"lte\") return a <= b;\n if (op === \"gt\") return a > b;\n if (op === \"gte\") return a >= b;\n }\n\n if (typeof a === \"number\" && typeof b === \"number\") {\n if (op === \"lt\") return a < b;\n if (op === \"lte\") return a <= b;\n if (op === \"gt\") return a > b;\n if (op === \"gte\") return a >= b;\n }\n\n if (typeof a === \"bigint\" && typeof b === \"bigint\") {\n if (op === \"lt\") return a < b;\n if (op === \"lte\") return a <= b;\n if (op === \"gt\") return a > b;\n if (op === \"gte\") return a >= b;\n }\n\n return \"unknown\";\n}\n"],"mappings":"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"}
|
|
@@ -150,7 +150,11 @@ function substituteRawPlaceholders(sql, replace) {
|
|
|
150
150
|
else if (c === "\"") end = skipQuoted(sql, i, "\"", false);
|
|
151
151
|
else if (c === "-" && sql[i + 1] === "-") {
|
|
152
152
|
const newline = sql.indexOf("\n", i);
|
|
153
|
-
|
|
153
|
+
if (newline === -1) {
|
|
154
|
+
out += `${sql.slice(i)}\n`;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
end = newline;
|
|
154
158
|
} else if (c === "/" && sql[i + 1] === "*") end = skipBlockComment(sql, i);
|
|
155
159
|
else if (c === "$") {
|
|
156
160
|
const tag = dollarQuoteTagAt(sql, i);
|
|
@@ -583,9 +587,18 @@ function quoteLiteral(value) {
|
|
|
583
587
|
if (typeof value === "number") return String(value);
|
|
584
588
|
return `'${value.replace(/'/g, "''")}'`;
|
|
585
589
|
}
|
|
586
|
-
/**
|
|
590
|
+
/**
|
|
591
|
+
* Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output.
|
|
592
|
+
*
|
|
593
|
+
* Each role goes through {@link quoteLiteral}, which leaves an ordinary name
|
|
594
|
+
* byte-for-byte as it is and doubles a quote inside one (`o'reilly`). An
|
|
595
|
+
* unescaped quote would close the literal early: a syntax error at
|
|
596
|
+
* `CREATE POLICY`, which leaves the table with RLS on and no policy, or — for a
|
|
597
|
+
* name built to do it — SQL of its own in the clause.
|
|
598
|
+
*/
|
|
587
599
|
function rolesArraySql(roles) {
|
|
588
|
-
|
|
600
|
+
if (roles.length === 0) return "ARRAY[]::text[]";
|
|
601
|
+
return `ARRAY[${[...roles].sort().map(quoteLiteral).join(",")}]`;
|
|
589
602
|
}
|
|
590
603
|
//#endregion
|
|
591
604
|
export { policyToPostgres };
|