@aglyn/plugins-data 1.0.0-beta.221 → 1.0.0-beta.222

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.
Files changed (135) hide show
  1. package/README.md +9 -6
  2. package/package.json +10 -10
  3. package/src/index.d.ts +1 -1
  4. package/src/index.js +1 -1
  5. package/src/index.js.map +1 -1
  6. package/src/lib/artifact/dataset-schema-artifact.server.d.ts +57 -0
  7. package/src/lib/artifact/dataset-schema-artifact.server.js +308 -0
  8. package/src/lib/artifact/dataset-schema-artifact.server.js.map +1 -0
  9. package/src/lib/artifact/dataset-schema.d.ts +136 -0
  10. package/src/lib/artifact/dataset-schema.js +232 -0
  11. package/src/lib/artifact/dataset-schema.js.map +1 -0
  12. package/src/lib/components/dataset-record-dialog.component.d.ts +1 -1
  13. package/src/lib/components/dataset-record-dialog.component.js +1 -1
  14. package/src/lib/components/dataset-record-dialog.component.js.map +1 -1
  15. package/src/lib/components/dataset-record-filter.d.ts +1 -1
  16. package/src/lib/components/dataset-record-filter.js +2 -2
  17. package/src/lib/components/dataset-record-filter.js.map +1 -1
  18. package/src/lib/components/dataset-schema-dialog.component.d.ts +1 -1
  19. package/src/lib/components/dataset-schema-dialog.component.js +55 -23
  20. package/src/lib/components/dataset-schema-dialog.component.js.map +1 -1
  21. package/src/lib/components/host-datasets-card.component.js +91 -403
  22. package/src/lib/components/host-datasets-card.component.js.map +1 -1
  23. package/src/lib/constants/bundle-common.d.ts +12 -0
  24. package/src/lib/constants/bundle-common.js +13 -0
  25. package/src/lib/constants/bundle-common.js.map +1 -1
  26. package/src/lib/declarations.console-server.d.ts +16 -1
  27. package/src/lib/declarations.console-server.js +34 -2
  28. package/src/lib/declarations.console-server.js.map +1 -1
  29. package/src/lib/declarations.server.d.ts +13 -0
  30. package/src/lib/declarations.server.js +48 -2
  31. package/src/lib/declarations.server.js.map +1 -1
  32. package/src/lib/form-target/dataset-form-record-target.server.js +5 -3
  33. package/src/lib/form-target/dataset-form-record-target.server.js.map +1 -1
  34. package/src/lib/model/dataset-models.d.ts +392 -0
  35. package/src/lib/model/dataset-models.js +806 -0
  36. package/src/lib/model/dataset-models.js.map +1 -0
  37. package/src/lib/model/dataset-record-view.d.ts +1 -1
  38. package/src/lib/model/dataset-record-view.js +1 -2
  39. package/src/lib/model/dataset-record-view.js.map +1 -1
  40. package/src/lib/model/dataset-record.js +2 -2
  41. package/src/lib/model/dataset-record.js.map +1 -1
  42. package/src/lib/model/datasets.d.ts +162 -0
  43. package/src/lib/model/datasets.js +227 -0
  44. package/src/lib/model/datasets.js.map +1 -0
  45. package/src/lib/plugin.d.ts +4 -0
  46. package/src/lib/plugin.js +40 -0
  47. package/src/lib/plugin.js.map +1 -1
  48. package/src/lib/record-pages/record-page-live-paths.server.d.ts +55 -0
  49. package/src/lib/record-pages/record-page-live-paths.server.js +83 -0
  50. package/src/lib/record-pages/record-page-live-paths.server.js.map +1 -0
  51. package/src/lib/record-pages/record-page-read.server.d.ts +70 -0
  52. package/src/lib/record-pages/record-page-read.server.js +175 -0
  53. package/src/lib/record-pages/record-page-read.server.js.map +1 -0
  54. package/src/lib/record-pages/record-page-resolver.server.d.ts +37 -0
  55. package/src/lib/record-pages/record-page-resolver.server.js +131 -0
  56. package/src/lib/record-pages/record-page-resolver.server.js.map +1 -0
  57. package/src/lib/record-pages/record-page-sitemap.server.d.ts +32 -0
  58. package/src/lib/record-pages/record-page-sitemap.server.js +102 -0
  59. package/src/lib/record-pages/record-page-sitemap.server.js.map +1 -0
  60. package/src/lib/record-pages/record-page-source.d.ts +42 -0
  61. package/src/lib/record-pages/record-page-source.js +176 -0
  62. package/src/lib/record-pages/record-page-source.js.map +1 -0
  63. package/src/lib/record-pages/record-pages-route.d.ts +42 -0
  64. package/src/lib/record-pages/record-pages-route.js +348 -0
  65. package/src/lib/record-pages/record-pages-route.js.map +1 -0
  66. package/src/lib/record-pages/record-pages.d.ts +218 -0
  67. package/src/lib/record-pages/record-pages.js +351 -0
  68. package/src/lib/record-pages/record-pages.js.map +1 -0
  69. package/src/lib/record-pages/record-template-row-chip.component.d.ts +28 -0
  70. package/src/lib/record-pages/record-template-row-chip.component.js +87 -0
  71. package/src/lib/record-pages/record-template-row-chip.component.js.map +1 -0
  72. package/src/lib/record-pages/record-template-section.component.d.ts +30 -0
  73. package/src/lib/record-pages/record-template-section.component.js +491 -0
  74. package/src/lib/record-pages/record-template-section.component.js.map +1 -0
  75. package/src/lib/repeat/dataset-repeat-rows.d.ts +2 -1
  76. package/src/lib/repeat/dataset-repeat-rows.js +30 -12
  77. package/src/lib/repeat/dataset-repeat-rows.js.map +1 -1
  78. package/src/lib/repeat/dataset-repeat-rows.server.d.ts +1 -1
  79. package/src/lib/repeat/dataset-repeat-rows.server.js +23 -12
  80. package/src/lib/repeat/dataset-repeat-rows.server.js.map +1 -1
  81. package/src/lib/repeat/repeat-record-pages.d.ts +48 -0
  82. package/src/lib/repeat/repeat-record-pages.js +64 -0
  83. package/src/lib/repeat/repeat-record-pages.js.map +1 -0
  84. package/src/lib/server/announce-dataset-records.d.ts +1 -1
  85. package/src/lib/server/announce-dataset-records.js +1 -1
  86. package/src/lib/server/announce-dataset-records.js.map +1 -1
  87. package/src/lib/server/api-v1/datasets.js +5 -3
  88. package/src/lib/server/api-v1/datasets.js.map +1 -1
  89. package/src/lib/server/dataset-figures.js +2 -2
  90. package/src/lib/server/dataset-figures.js.map +1 -1
  91. package/src/lib/server/dataset-live-pages.d.ts +147 -0
  92. package/src/lib/server/dataset-live-pages.js +436 -0
  93. package/src/lib/server/dataset-live-pages.js.map +1 -0
  94. package/src/lib/server/dataset-steps.server.d.ts +45 -0
  95. package/src/lib/server/dataset-steps.server.js +268 -0
  96. package/src/lib/server/dataset-steps.server.js.map +1 -0
  97. package/src/lib/server/datasets-route.d.ts +2 -0
  98. package/src/lib/server/datasets-route.js +102 -6
  99. package/src/lib/server/datasets-route.js.map +1 -1
  100. package/src/lib/server/resolve-dataset.d.ts +40 -0
  101. package/src/lib/server/resolve-dataset.js +64 -0
  102. package/src/lib/server/resolve-dataset.js.map +1 -0
  103. package/src/lib/server.d.ts +3 -2
  104. package/src/lib/server.js +8 -9
  105. package/src/lib/server.js.map +1 -1
  106. package/src/lib/site-bundle/datasets-package.d.ts +42 -0
  107. package/src/lib/site-bundle/datasets-package.js +95 -0
  108. package/src/lib/site-bundle/datasets-package.js.map +1 -0
  109. package/src/lib/site-bundle/datasets-site-bundle.server.js +2 -2
  110. package/src/lib/site-bundle/datasets-site-bundle.server.js.map +1 -1
  111. package/src/lib/{model/index.js → tenant-api.d.ts} +12 -7
  112. package/src/lib/tenant-api.js +30 -0
  113. package/src/lib/tenant-api.js.map +1 -0
  114. package/src/lib/transfer/dataset-transfer-key.d.ts +30 -0
  115. package/src/lib/transfer/dataset-transfer-key.js +31 -0
  116. package/src/lib/transfer/dataset-transfer-key.js.map +1 -0
  117. package/src/lib/transfer/dataset-transfer-model.d.ts +146 -0
  118. package/src/lib/transfer/dataset-transfer-model.js +536 -0
  119. package/src/lib/transfer/dataset-transfer-model.js.map +1 -0
  120. package/src/lib/{model/index.d.ts → transfer/dataset-transfer-resource.d.ts} +2 -6
  121. package/src/lib/transfer/dataset-transfer-resource.js +41 -0
  122. package/src/lib/transfer/dataset-transfer-resource.js.map +1 -0
  123. package/src/lib/transfer/dataset-transfer.server.d.ts +69 -0
  124. package/src/lib/transfer/dataset-transfer.server.js +930 -0
  125. package/src/lib/transfer/dataset-transfer.server.js.map +1 -0
  126. package/src/lib/model/dataset-csv.d.ts +0 -36
  127. package/src/lib/model/dataset-csv.js +0 -85
  128. package/src/lib/model/dataset-csv.js.map +0 -1
  129. package/src/lib/model/dataset-io.d.ts +0 -44
  130. package/src/lib/model/dataset-io.js +0 -85
  131. package/src/lib/model/dataset-io.js.map +0 -1
  132. package/src/lib/model/index.js.map +0 -1
  133. package/src/lib/server/datasets-export-route.d.ts +0 -57
  134. package/src/lib/server/datasets-export-route.js +0 -229
  135. package/src/lib/server/datasets-export-route.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/server/datasets-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n defaultScopeForNewResource,\n newResourceScopeFields,\n pluginRequestFromWeb,\n type PluginWebApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n checkDatasetQuota,\n checkEntitlement,\n checkQuota,\n coerceDocumentValues,\n createResourceUid,\n datasetIntegrityFields,\n effectiveDatasetModel,\n ensureDeclaredCustomFieldTypes,\n memberCanSee,\n validateDocument,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n dataStorageRefusal,\n isImpersonationSession,\n isServerReleaseFlagOnForOrg,\n lockdownRefusal,\n memberHasOrgPermission,\n resolveOrgIdForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { Timestamp } from 'firebase-admin/firestore'\nimport { announceDatasetRecords as announceDatasetChange } from './announce-dataset-records'\n\n/**\n * `dataStorageMbPerOrg` for this route, rendered as the console's 403.\n *\n * The VERDICT moved to `utils/server/data-storage-gate` (AGL-2253) — it was\n * defined here, so `/v1` and the tenant form path, which write dataset bytes\n * through their own code, enforced nothing. Only the console wording is left\n * behind, because the other two callers answer different shapes.\n */\nasync function refuseIfDataStorageBlocked(\n org: unknown,\n orgRef: FirebaseFirestore.DocumentReference,\n): Promise<Response | null> {\n const refusal = await dataStorageRefusal(org, orgRef)\n if (!refusal) return null\n return Response.json(\n {\n error:\n refusal.basis === 'always'\n ? 'Dataset storage is not included on this plan — upgrade in Billing'\n : `Dataset storage limit reached (${refusal.includedMb} MB) — ` +\n 'upgrade in Billing',\n },\n { status: 403 },\n )\n}\n\n/** Roles allowed to create org data — mirrors rules' canWriteOrgData(). */\nconst WRITER_ROLES = new Set(['owner', 'admin', 'editor'])\n\n/**\n * Rows written per import transaction.\n *\n * Firestore allows at most 500 writes in one transaction, and this is the\n * chunk size the route already used for its batches, so nothing about the\n * shape of an import changed when the batches became transactions (AGL-2371).\n */\nconst IMPORT_CHUNK = 400\n\n/**\n * Dataset/record creation API (AGL-473): creates moved out of the client\n * SDK so quotas and entitlements are enforced server-side — Firestore\n * rules deny client-side `create` on `orgs/{orgId}/datasets/**` (updates\n * and deletes stay client-direct, they don't consume quota). Actions:\n *\n * - `create-dataset`: `dataStore` entitlement + `checkDatasetQuota`\n * (addon-aware, org-scoped). An optional `hostId` names the site the\n * dataset is created from, which the org's Default sharing can narrow it\n * to.\n * - `create-record`: `recordsPerDataset` quota; values are re-coerced\n * and re-validated against the dataset's model server-side.\n * - `import-records`: batch create with the whole batch fitting the cap.\n *\n * Since AGL-2163 both record actions ALSO enforce `checkDataStorageQuota`.\n * See {@link refuseIfDataStorageBlocked}: that check's `allowed` field had no\n * reader anywhere in the platform while its docblock said free \"hard-blocks\n * at the included size\", so the byte band was documentation. `recordsPerDataset`\n * counts ROWS and does not bound their size, so it was never the same limit.\n */\nexport const datasetsHandler: PluginWebApiHandler = async (request) => {\n const { method, body, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n\n const orgId = String(body?.orgId ?? '')\n const action = String(body?.action ?? '')\n if (!orgId) return Response.json({ error: 'Missing orgId' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId)\n const member = membership?.member as any\n if (!member || !WRITER_ROLES.has(String(member.role))) {\n return Response.json({ error: 'Editing org data requires the editor role' }, { status: 403 })\n }\n /*==========================================\n * AND the granular permission (AGL-2444).\n *\n * `data.manage` had ZERO consumers: an owner could build a custom role\n * with \"Manage data\" unticked, assign it, and the member kept creating,\n * editing and deleting datasets — because this gate read the raw role,\n * exactly the defect AGL-2350 fixed one route over on `hosts.create`.\n *\n * Identical for the built-in roles — `data.manage` is on for owner,\n * admin and editor, which is `WRITER_ROLES` — so nobody's access moves.\n * What it adds is that a custom role or a per-member override is now\n * honoured, which is the narrowing the docs sell.\n *\n * The role check is kept ALONGSIDE it rather than replaced. It mirrors\n * the rules' `canWriteOrgData()`, and a route that disagreed with the\n * rules would let one door open while the other stayed shut.\n *=========================================*/\n if (\n decoded['staff'] !== true &&\n !(await memberHasOrgPermission(orgId, member, 'data.manage'))\n ) {\n return Response.json({\n error: 'Your organization role does not allow editing organization data',\n }, { status: 403 })\n }\n\n // Release gate (AGL-1653). `<FeatureGate flag=\"release_data_store\">` on\n // the org Data page was the only gate, so org-level dataset\n // administration kept working with the flag off — the milder sibling of\n // the add-on store leak above it in the same issue. Same staff bypass as\n // `FeatureGate`'s `visible`, same 404: released-off means absent.\n if (\n decoded['staff'] !== true &&\n !(await isServerReleaseFlagOnForOrg('release_data_store', orgId))\n ) {\n return Response.json({ error: 'Not available' }, { status: 404 })\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) {\n return Response.json({ error: 'Unknown organization' }, { status: 404 })\n }\n const org = orgSnapshot.data() as any\n\n // Lockdown verdict (AGL-1506): subsumes the bare `orgSuspended`\n // projection check this route used to make — the org doc is read just\n // above anyway, so the full verdict (platform/org/user scopes, staff\n // bypass, distinct 423 body) costs no extra org read here.\n const locked = await lockdownRefusal({\n request,\n staff: decoded['staff'] === true,\n uid: decoded.uid,\n org,\n })\n if (locked) return locked\n\n if (action === 'create-dataset') {\n if (!checkEntitlement(org, 'dataStore')) {\n return Response.json({\n error: 'Datasets require a Starter plan or higher',\n }, { status: 403 })\n }\n const displayName = String(body?.displayName ?? '').trim().slice(0, 120)\n const fields = Array.isArray(body?.fields)\n ? (body.fields as unknown[]).map((field) => String(field)).slice(0, 100)\n : []\n if (!displayName || fields.length === 0) {\n return Response.json({ error: 'Missing displayName or fields' }, { status: 400 })\n }\n /**\n * The site the dataset is created from — null on the organization Data\n * page, which has none.\n *\n * It reaches the document only through the org's Default sharing: with\n * `defaultResourceScope: 'host'` the new dataset is visible to this site\n * alone. That makes it a client claim deciding who can see the result,\n * so it is honored only when it holds on both counts:\n *\n * * The site is one of THIS org's. `hostIndex` is the mirror the Data\n * card resolved `orgId` from in the first place, so a create made\n * from a real site page cannot disagree with it. Another org's site\n * would stamp a token that no site and no scoped member of this org\n * holds: a dataset only the org's admins could ever find.\n * * The caller's own access reaches the scope it produces. A scoped\n * member naming a site outside their access would create a dataset\n * they cannot see afterwards, which is the create the rules'\n * `canCreateScoped()` refuses on the collections clients create.\n *\n * Both are refused rather than widened to All sites. Widening is the\n * direction that shows a dataset to sites nobody chose.\n */\n const hostId = String(body?.hostId ?? '').trim() || null\n if (\n hostId &&\n (hostId.includes('/') || (await resolveOrgIdForHost(hostId)) !== orgId)\n ) {\n return Response.json(\n { error: 'That site is not one of this organization’s' },\n { status: 400 },\n )\n }\n // Honors the org's `defaultResourceScope` (AGL-1048), falling back to\n // org-wide — which is also the only answer with no site in context.\n const visibleTo = defaultScopeForNewResource({\n defaultResourceScope: (org as {\n defaultResourceScope?: 'org' | 'host'\n })?.defaultResourceScope,\n hostId,\n })\n if (\n hostId &&\n decoded['staff'] !== true &&\n !memberCanSee(member, visibleTo)\n ) {\n return Response.json(\n { error: 'Your access to this organization does not reach that site' },\n { status: 403 },\n )\n }\n const datasetsRef = orgRef.collection('datasets')\n const overDatasetQuota = (quota: ReturnType<typeof checkDatasetQuota>) =>\n quota.upgradeRequired\n ? `Dataset limit reached (${quota.limit}) — upgrade in Billing`\n : `Dataset limit reached (${quota.limit}) — add extra datasets ` +\n `for $${quota.addonPriceUsd}/mo each or upgrade in Billing`\n // The fast refusal. NOT the enforcement point — that is the transaction\n // below — but it answers a caller who is simply over their limit before\n // the request builds anything.\n const quota = checkDatasetQuota(\n org,\n (await datasetsRef.count().get()).data().count,\n )\n if (!quota.allowed) {\n return Response.json({ error: overDatasetQuota(quota) }, { status: 403 })\n }\n const id = createResourceUid()\n // The model rides from the console (deriveModelFromFields output or\n // the join-collection template). `model` is independent of `fields`,\n // so cap its serialized size explicitly (a legit model is well under\n // this — 64 KB covers hundreds of typed fields).\n const model = body?.model && typeof body.model === 'object' ? body.model : null\n if (model && JSON.stringify(model).length > 64 * 1024) {\n return Response.json({ error: 'Dataset model too large' }, { status: 413 })\n }\n /**\n * THE ENFORCEMENT POINT (AGL-2371, the AGL-2231 treatment): the count,\n * the decision and the create in ONE transaction.\n *\n * The check above counted, then the request went on awaiting, and only\n * then created — and every await is a yield, so N concurrent creates\n * each read the same pre-count, each found room, and each landed.\n * Nothing re-counts afterwards, so the extra datasets were permanent and\n * billed nothing. `tx.get` on the aggregate takes a pessimistic lock on\n * the documents the query matched, so the loser of a race retries,\n * re-reads the higher count and is refused.\n *\n * The ORG document is deliberately read outside: the plan and the\n * purchased add-ons are the other input to this decision, but neither is\n * client-writable, so locking the org doc on every dataset create would\n * buy contention rather than correctness. That is the AGL-2369 lesson\n * applied, not skipped — there the routing map *was* client-writable and\n * *did* change the count.\n *\n * The refusal comes back as data and is rendered outside: building a\n * response inside a body that can run several times reads as if the\n * transaction were a place effects happen.\n */\n const refusal = await firestore.runTransaction<string | null>(\n async (tx) => {\n const live = (await tx.get(datasetsRef.count())).data().count\n const authoritative = checkDatasetQuota(org, live)\n if (!authoritative.allowed) return overDatasetQuota(authoritative)\n tx.create(datasetsRef.doc(id), {\n displayName,\n fields,\n ...(model ? { model } : {}),\n // The scope decided above. Stamping SOMETHING is mandatory: an\n // `array-contains-any` matches nothing on a doc missing the field,\n // so an unstamped dataset renders on no site at all (AGL-1044).\n //\n // Through `newResourceScopeFields` since AGL-1484: AGL-1478 built\n // that helper as the type-level gate for exactly the collections\n // with no document constructor of their own — `datasets` named\n // first among them — and then only the marketplace fork used it,\n // while the two ordinary dataset creates went on writing the field\n // by hand. A required argument that three of four callers bypass\n // is a convention, not a guarantee.\n ...newResourceScopeFields(visibleTo),\n createdAt: Timestamp.now(),\n })\n return null\n },\n )\n if (refusal) return Response.json({ error: refusal }, { status: 403 })\n return Response.json({ ok: true, id }, { status: 200 })\n }\n\n if (\n action === 'create-record' ||\n action === 'import-records' ||\n action === 'announce-records'\n ) {\n const datasetId = String(body?.datasetId ?? '')\n if (!datasetId) {\n return Response.json({ error: 'Missing datasetId' }, { status: 400 })\n }\n const datasetRef = orgRef.collection('datasets').doc(datasetId)\n const datasetSnapshot = await datasetRef.get()\n if (!datasetSnapshot.exists) {\n return Response.json({ error: 'Unknown dataset' }, { status: 404 })\n }\n /**\n * The caller has to be able to SEE the dataset they add rows to.\n *\n * The role and `data.manage` gates above are about the org, and a member\n * scoped to one site passes both. The rules hold every record update and\n * delete to the parent dataset being visible (`parentDatasetVisible()`),\n * and refuse client record creates outright, so this route is the one\n * place a member's create can be held to the same condition. Without it,\n * a collaborator on one client's site could add rows, by id alone, to an\n * internal dataset shared only with other sites.\n *\n * The Admin SDK evaluates no rules, so `memberCanSee` IS the enforcement\n * here, as on the export route beside this one. Answered with the same\n * 404 as a dataset that does not exist: whether one outside the caller's\n * reach exists is not theirs to learn either.\n */\n if (\n decoded['staff'] !== true &&\n !memberCanSee(\n member,\n (datasetSnapshot.data() as { visibleTo?: string[] } | undefined)\n ?.visibleTo,\n )\n ) {\n return Response.json({ error: 'Unknown dataset' }, { status: 404 })\n }\n /**\n * A record the BROWSER wrote, announcing itself (AGL-3113).\n *\n * Record edits and deletes stay client-direct on purpose — they consume\n * no quota, so AGL-473 left them out of the server leg — which also left\n * them with nothing to announce from. They are the same staleness as a\n * create: a page listing the rows goes on showing the old one.\n *\n * A drop, and only a drop. It moves no pointer, writes nothing and\n * registers no route, so it grants nothing the rules withhold — and it\n * is gated by the same org membership and the same `memberCanSee` the\n * create is, which is what stops a collaborator on one site from\n * learning that another site's dataset exists.\n */\n if (action === 'announce-records') {\n const announced = await announceDatasetChange({\n firestore,\n orgId,\n datasetId,\n })\n return Response.json({ ok: true, announced }, { status: 200 })\n }\n const model = effectiveDatasetModel(datasetSnapshot.data() as any)\n // Before either validation below: a plugin's field validator only runs\n // once its plugin's server entry has registered it.\n await ensureDeclaredCustomFieldTypes(model)\n const recordsRef = datasetRef.collection('records')\n const recordCount = (await recordsRef.count().get()).data().count\n const overRecordQuota = (limit: number) =>\n `Record limit reached (${limit}) — upgrade in Billing`\n\n if (action === 'create-record') {\n const coerced = coerceDocumentValues(model, body?.values ?? {})\n const errors = validateDocument(model, coerced)\n if (Object.keys(errors).length) {\n return Response.json({ error: 'Record failed validation', errors }, { status: 400 })\n }\n // The fast refusal. NOT the enforcement point — that is the\n // transaction below — but it keeps the refusal ORDER on this route\n // intact: a caller over BOTH the row cap and the byte band is told\n // about the rows, as they always were.\n const quota = checkQuota(org, 'recordsPerDataset', recordCount)\n if (!quota.allowed) {\n return Response.json({\n error: overRecordQuota(quota.limit),\n }, { status: 403 })\n }\n // Bytes, not rows (AGL-2163). Before the write, like every other\n // quota on this route.\n const storageRefusal = await refuseIfDataStorageBlocked(org, orgRef)\n if (storageRefusal) return storageRefusal\n const id = createResourceUid()\n /**\n * THE ENFORCEMENT POINT (AGL-2371, the AGL-2231 treatment): the count,\n * the decision and the create in ONE transaction.\n *\n * The check above counted, then `refuseIfDataStorageBlocked` awaited,\n * and only then did the row land. Every await is a yield, so N\n * concurrent creates each read the same pre-count, each found room,\n * and each landed — permanently, since nothing re-counts afterwards. A\n * free-with-rows-granted org's thousand rows became twenty thousand by\n * sending twenty requests at once.\n *\n * `tx.get` on the aggregate takes a pessimistic lock on the documents\n * the query matched, so the loser retries, re-reads the higher count\n * and is refused. `order` is taken from the transaction's own count\n * for the same reason: the pre-count is what made two concurrent rows\n * share an order.\n *\n * The BYTE gate stays outside. It is a different quota with its own\n * reads, it is not a function of the rows this transaction writes, and\n * re-running it on every retry attempt would put a multi-document read\n * inside a lock.\n */\n const refusal = await firestore.runTransaction<string | null>(\n async (tx) => {\n const live = (await tx.get(recordsRef.count())).data().count\n const authoritative = checkQuota(org, 'recordsPerDataset', live)\n if (!authoritative.allowed) return overRecordQuota(authoritative.limit)\n tx.create(recordsRef.doc(id), {\n values: coerced,\n // The integrity index the delete check queries. Written on the\n // same write as the values it describes — an index that lags\n // them reports a reference the record no longer holds, or\n // misses one it does.\n ...datasetIntegrityFields(model, coerced),\n order: live,\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n })\n return null\n },\n )\n if (refusal) return Response.json({ error: refusal }, { status: 403 })\n // The record is stored; now the pages that repeat over it are told\n // (AGL-3113). Awaited so the site is already serving the new row by the\n // time the editor's save returns, and reported so a refused drop can\n // say the live pages are behind rather than leaving it to be noticed.\n const announced = await announceDatasetChange({\n firestore,\n orgId,\n datasetId,\n })\n return Response.json({ ok: true, id, announced }, { status: 200 })\n }\n\n // import-records: the console sends only the NEW rows (updates to\n // existing ids stay client-direct — they don't consume quota).\n const rows = Array.isArray(body?.records)\n ? (body.records as Array<{ values?: unknown }>)\n : []\n if (rows.length === 0) {\n return Response.json({ error: 'No records to import' }, { status: 400 })\n }\n const overImportQuota = (limit: number, needed: number) =>\n `This import needs ${needed} record slots — your plan ` +\n `allows ${limit} per dataset. See Billing to upgrade.`\n // The fast refusal, on the whole import. NOT the enforcement point —\n // that is the per-chunk transaction below — but it is what answers an\n // import that never fitted, before a single row is written.\n const quota = checkQuota(\n org,\n 'recordsPerDataset',\n recordCount + rows.length - 1,\n )\n if (!quota.allowed) {\n return Response.json({\n error: overImportQuota(quota.limit, rows.length),\n }, { status: 403 })\n }\n // Bytes, not rows (AGL-2163). An import is the path that moves the\n // most bytes in one request, so leaving it out would have left the\n // enforcement exactly where the customer does not meet it.\n const importStorageRefusal = await refuseIfDataStorageBlocked(org, orgRef)\n if (importStorageRefusal) return importStorageRefusal\n const prepared = rows.map((row, index) => {\n const coerced = coerceDocumentValues(\n model,\n (row?.values ?? {}) as Record<string, unknown>,\n )\n const errors = validateDocument(model, coerced)\n return { index, coerced, valid: Object.keys(errors).length === 0 }\n })\n const invalid = prepared.filter((row) => !row.valid)\n if (invalid.length) {\n return Response.json({\n error: `${invalid.length} rows failed validation`,\n rows: invalid.map((row) => row.index),\n }, { status: 400 })\n }\n /**\n * THE ENFORCEMENT POINT (AGL-2371): each chunk counts, decides and\n * writes inside ONE transaction.\n *\n * The import used to count once, decide once, and then commit a series\n * of `WriteBatch`es. A batch is atomic but NOT conditional on a read\n * taken before it, so two concurrent imports each priced themselves off\n * the same pre-count and both landed in full — the create leg's defect,\n * multiplied by the batch size.\n *\n * It cannot be ONE transaction for every import the way the single-row\n * create can: `recordsPerDataset` runs to a million, and a transaction\n * carries at most 500 writes. So the chunk is the unit, and each chunk's\n * count is re-read under the lock and re-checked against the plan. The\n * cap therefore holds ABSOLUTELY at every size — what a race can cost is\n * a partial import, not an overshoot, and a partial import was already\n * possible the moment a mid-import batch could fail.\n *\n * The ids are minted before the transaction so a retry re-creates the\n * SAME documents rather than a second copy of the chunk.\n *\n * This is not AGL-2370's problem in miniature. There the whole bundle\n * must land or none of it, so chunking cannot be the unit and a lease is\n * needed; here rows are independent and \"fewer rows than asked for\" is a\n * reportable outcome.\n */\n const ids: string[] = []\n let refusedAt: string | null = null\n for (\n let start = 0;\n start < prepared.length && refusedAt === null;\n start += IMPORT_CHUNK\n ) {\n const chunk = prepared.slice(start, start + IMPORT_CHUNK)\n const chunkIds = chunk.map(() => createResourceUid())\n refusedAt = await firestore.runTransaction<string | null>(async (tx) => {\n const live = (await tx.get(recordsRef.count())).data().count\n const authoritative = checkQuota(\n org,\n 'recordsPerDataset',\n live + chunk.length - 1,\n )\n if (!authoritative.allowed) {\n return overImportQuota(authoritative.limit, rows.length)\n }\n chunk.forEach((row, offset) => {\n tx.create(recordsRef.doc(chunkIds[offset]), {\n values: row.coerced,\n ...datasetIntegrityFields(model, row.coerced),\n order: live + offset,\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n })\n })\n return null\n })\n if (refusedAt === null) ids.push(...chunkIds)\n }\n /**\n * ONE announce for the whole import, after the loop (AGL-3113).\n *\n * Not per chunk and certainly not per row: a thousand rows arrive as one\n * import and make the same pages stale once, so the pages are worked out\n * once and dropped once. The refusal path announces too — rows that\n * landed before the quota bit are live records, and leaving them out of\n * the refresh would make a partial import look like no import at all.\n */\n if (ids.length) {\n await announceDatasetChange({ firestore, orgId, datasetId })\n }\n if (refusedAt !== null) {\n // What landed is reported alongside the refusal. Rows are independent,\n // so silently discarding the created ids would tell the caller nothing\n // was written while the dataset says otherwise.\n return Response.json(\n { error: refusedAt, ids, created: ids.length },\n { status: 403 },\n )\n }\n return Response.json({ ok: true, ids, created: ids.length }, { status: 200 })\n }\n\n return Response.json({ error: 'Unknown action' }, { status: 400 })\n } catch (error) {\n // A refused credential is a 401, not a fault of ours (AGL-1993); anything\n // else keeps the answer below.\n if (isRefusedIdToken(error)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n console.error(error)\n return Response.json({ error: 'Dataset operation failed' }, { status: 500 })\n }\n}\n"],"names":["defaultScopeForNewResource","newResourceScopeFields","pluginRequestFromWeb","checkDatasetQuota","checkEntitlement","checkQuota","coerceDocumentValues","createResourceUid","datasetIntegrityFields","effectiveDatasetModel","ensureDeclaredCustomFieldTypes","memberCanSee","validateDocument","emailUnverifiedResponse","firebaseAdmin","dataStorageRefusal","isImpersonationSession","isServerReleaseFlagOnForOrg","lockdownRefusal","memberHasOrgPermission","resolveOrgIdForHost","resolveOrgMembership","isRefusedIdToken","Timestamp","announceDatasetRecords","announceDatasetChange","refuseIfDataStorageBlocked","org","orgRef","refusal","Response","json","error","basis","includedMb","status","WRITER_ROLES","Set","IMPORT_CHUNK","datasetsHandler","request","headers","method","body","rawHeaders","authorization","idToken","startsWith","slice","length","undefined","orgId","String","action","decoded","app","auth","verifyIdToken","email_verified","membership","uid","member","has","role","firestore","collection","doc","orgSnapshot","get","exists","data","locked","staff","displayName","trim","fields","Array","isArray","map","field","hostId","includes","visibleTo","defaultResourceScope","datasetsRef","overDatasetQuota","quota","upgradeRequired","limit","addonPriceUsd","count","allowed","id","model","JSON","stringify","runTransaction","tx","live","authoritative","create","createdAt","now","ok","datasetSnapshot","datasetId","datasetRef","announced","recordsRef","recordCount","overRecordQuota","coerced","values","errors","Object","keys","storageRefusal","order","updatedAt","rows","records","overImportQuota","needed","importStorageRefusal","prepared","row","index","valid","invalid","filter","ids","refusedAt","start","chunk","chunkIds","forEach","offset","push","created","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/server/datasets-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n defaultScopeForNewResource,\n newResourceScopeFields,\n pluginRequestFromWeb,\n type PluginWebApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n checkDatasetQuota,\n checkEntitlement,\n checkQuota,\n createResourceUid,\n ensureDeclaredCustomFieldTypes,\n memberCanSee,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n dataStorageRefusal,\n isImpersonationSession,\n isServerReleaseFlagOnForOrg,\n lockdownRefusal,\n memberHasOrgPermission,\n resolveOrgIdForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { announceDatasetRecords as announceDatasetChange } from './announce-dataset-records'\nimport { coerceDocumentValues, datasetIntegrityFields, datasetIntegrityUpdate, effectiveDatasetModel, validateDocument } from '../model/dataset-models'\nimport { defaultDatasetFieldId } from '../model/datasets'\nimport {\n RECORD_PAGE_ADDRESS_FIELD_TYPE,\n fillRecordAddresses,\n isRecordAddressField,\n recordAddressFieldIds,\n recordAddressOf,\n uniqueRecordAddresses,\n} from '../record-pages/record-pages'\n\n/**\n * `dataStorageMbPerOrg` for this route, rendered as the console's 403.\n *\n * The VERDICT moved to `utils/server/data-storage-gate` (AGL-2253) — it was\n * defined here, so `/v1` and the tenant form path, which write dataset bytes\n * through their own code, enforced nothing. Only the console wording is left\n * behind, because the other two callers answer different shapes.\n */\nasync function refuseIfDataStorageBlocked(\n org: unknown,\n orgRef: FirebaseFirestore.DocumentReference,\n): Promise<Response | null> {\n const refusal = await dataStorageRefusal(org, orgRef)\n if (!refusal) return null\n return Response.json(\n {\n error:\n refusal.basis === 'always'\n ? 'Dataset storage is not included on this plan — upgrade in Billing'\n : `Dataset storage limit reached (${refusal.includedMb} MB) — ` +\n 'upgrade in Billing',\n },\n { status: 403 },\n )\n}\n\n/** Roles allowed to create org data — mirrors rules' canWriteOrgData(). */\nconst WRITER_ROLES = new Set(['owner', 'admin', 'editor'])\n\n/**\n * Rows written per import transaction.\n *\n * Firestore allows at most 500 writes in one transaction, and this is the\n * chunk size the route already used for its batches, so nothing about the\n * shape of an import changed when the batches became transactions (AGL-2371).\n */\nconst IMPORT_CHUNK = 400\n\n/**\n * How many records one \"give it page addresses\" call fills (AGL-3475). Far\n * past a services or locations dataset; a larger one reports `truncated`, and\n * the next call fills the next records, since a filled record is skipped.\n */\nconst ADDRESS_FILL_MAX = 5000\n\n/**\n * Dataset/record creation API (AGL-473): creates moved out of the client\n * SDK so quotas and entitlements are enforced server-side — Firestore\n * rules deny client-side `create` on `orgs/{orgId}/datasets/**` (updates\n * and deletes stay client-direct, they don't consume quota). Actions:\n *\n * - `create-dataset`: `dataStore` entitlement + `checkDatasetQuota`\n * (addon-aware, org-scoped). An optional `hostId` names the site the\n * dataset is created from, which the org's Default sharing can narrow it\n * to.\n * - `create-record`: `recordsPerDataset` quota; values are re-coerced\n * and re-validated against the dataset's model server-side.\n * - `import-records`: batch create with the whole batch fitting the cap.\n * - `add-address-field`: a \"Page address\" field that fills in from a text\n * field, and a unique address on every record that has none (AGL-3475).\n *\n * Since AGL-2163 both record actions ALSO enforce `checkDataStorageQuota`.\n * See {@link refuseIfDataStorageBlocked}: that check's `allowed` field had no\n * reader anywhere in the platform while its docblock said free \"hard-blocks\n * at the included size\", so the byte band was documentation. `recordsPerDataset`\n * counts ROWS and does not bound their size, so it was never the same limit.\n */\nexport const datasetsHandler: PluginWebApiHandler = async (request) => {\n const { method, body, headers: rawHeaders } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n\n const orgId = String(body?.orgId ?? '')\n const action = String(body?.action ?? '')\n if (!orgId) return Response.json({ error: 'Missing orgId' }, { status: 400 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const membership = await resolveOrgMembership(decoded.uid, orgId)\n const member = membership?.member as any\n if (!member || !WRITER_ROLES.has(String(member.role))) {\n return Response.json({ error: 'Editing org data requires the editor role' }, { status: 403 })\n }\n /*==========================================\n * AND the granular permission (AGL-2444).\n *\n * `data.manage` had ZERO consumers: an owner could build a custom role\n * with \"Manage data\" unticked, assign it, and the member kept creating,\n * editing and deleting datasets — because this gate read the raw role,\n * exactly the defect AGL-2350 fixed one route over on `hosts.create`.\n *\n * Identical for the built-in roles — `data.manage` is on for owner,\n * admin and editor, which is `WRITER_ROLES` — so nobody's access moves.\n * What it adds is that a custom role or a per-member override is now\n * honoured, which is the narrowing the docs sell.\n *\n * The role check is kept ALONGSIDE it rather than replaced. It mirrors\n * the rules' `canWriteOrgData()`, and a route that disagreed with the\n * rules would let one door open while the other stayed shut.\n *=========================================*/\n if (\n decoded['staff'] !== true &&\n !(await memberHasOrgPermission(orgId, member, 'data.manage'))\n ) {\n return Response.json({\n error: 'Your organization role does not allow editing organization data',\n }, { status: 403 })\n }\n\n // Release gate (AGL-1653). `<FeatureGate flag=\"release_data_store\">` on\n // the org Data page was the only gate, so org-level dataset\n // administration kept working with the flag off — the milder sibling of\n // the add-on store leak above it in the same issue. Same staff bypass as\n // `FeatureGate`'s `visible`, same 404: released-off means absent.\n if (\n decoded['staff'] !== true &&\n !(await isServerReleaseFlagOnForOrg('release_data_store', orgId))\n ) {\n return Response.json({ error: 'Not available' }, { status: 404 })\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) {\n return Response.json({ error: 'Unknown organization' }, { status: 404 })\n }\n const org = orgSnapshot.data() as any\n\n // Lockdown verdict (AGL-1506): subsumes the bare `orgSuspended`\n // projection check this route used to make — the org doc is read just\n // above anyway, so the full verdict (platform/org/user scopes, staff\n // bypass, distinct 423 body) costs no extra org read here.\n const locked = await lockdownRefusal({\n request,\n staff: decoded['staff'] === true,\n uid: decoded.uid,\n org,\n })\n if (locked) return locked\n\n if (action === 'create-dataset') {\n if (!checkEntitlement(org, 'dataStore')) {\n return Response.json({\n error: 'Datasets require a Starter plan or higher',\n }, { status: 403 })\n }\n const displayName = String(body?.displayName ?? '').trim().slice(0, 120)\n const fields = Array.isArray(body?.fields)\n ? (body.fields as unknown[]).map((field) => String(field)).slice(0, 100)\n : []\n if (!displayName || fields.length === 0) {\n return Response.json({ error: 'Missing displayName or fields' }, { status: 400 })\n }\n /**\n * The site the dataset is created from — null on the organization Data\n * page, which has none.\n *\n * It reaches the document only through the org's Default sharing: with\n * `defaultResourceScope: 'host'` the new dataset is visible to this site\n * alone. That makes it a client claim deciding who can see the result,\n * so it is honored only when it holds on both counts:\n *\n * * The site is one of THIS org's. `hostIndex` is the mirror the Data\n * card resolved `orgId` from in the first place, so a create made\n * from a real site page cannot disagree with it. Another org's site\n * would stamp a token that no site and no scoped member of this org\n * holds: a dataset only the org's admins could ever find.\n * * The caller's own access reaches the scope it produces. A scoped\n * member naming a site outside their access would create a dataset\n * they cannot see afterwards, which is the create the rules'\n * `canCreateScoped()` refuses on the collections clients create.\n *\n * Both are refused rather than widened to All sites. Widening is the\n * direction that shows a dataset to sites nobody chose.\n */\n const hostId = String(body?.hostId ?? '').trim() || null\n if (\n hostId &&\n (hostId.includes('/') || (await resolveOrgIdForHost(hostId)) !== orgId)\n ) {\n return Response.json(\n { error: 'That site is not one of this organization’s' },\n { status: 400 },\n )\n }\n // Honors the org's `defaultResourceScope` (AGL-1048), falling back to\n // org-wide — which is also the only answer with no site in context.\n const visibleTo = defaultScopeForNewResource({\n defaultResourceScope: (org as {\n defaultResourceScope?: 'org' | 'host'\n })?.defaultResourceScope,\n hostId,\n })\n if (\n hostId &&\n decoded['staff'] !== true &&\n !memberCanSee(member, visibleTo)\n ) {\n return Response.json(\n { error: 'Your access to this organization does not reach that site' },\n { status: 403 },\n )\n }\n const datasetsRef = orgRef.collection('datasets')\n const overDatasetQuota = (quota: ReturnType<typeof checkDatasetQuota>) =>\n quota.upgradeRequired\n ? `Dataset limit reached (${quota.limit}) — upgrade in Billing`\n : `Dataset limit reached (${quota.limit}) — add extra datasets ` +\n `for $${quota.addonPriceUsd}/mo each or upgrade in Billing`\n // The fast refusal. NOT the enforcement point — that is the transaction\n // below — but it answers a caller who is simply over their limit before\n // the request builds anything.\n const quota = checkDatasetQuota(\n org,\n (await datasetsRef.count().get()).data().count,\n )\n if (!quota.allowed) {\n return Response.json({ error: overDatasetQuota(quota) }, { status: 403 })\n }\n const id = createResourceUid()\n // The model rides from the console (deriveModelFromFields output or\n // the join-collection template). `model` is independent of `fields`,\n // so cap its serialized size explicitly (a legit model is well under\n // this — 64 KB covers hundreds of typed fields).\n const model = body?.model && typeof body.model === 'object' ? body.model : null\n if (model && JSON.stringify(model).length > 64 * 1024) {\n return Response.json({ error: 'Dataset model too large' }, { status: 413 })\n }\n /**\n * THE ENFORCEMENT POINT (AGL-2371, the AGL-2231 treatment): the count,\n * the decision and the create in ONE transaction.\n *\n * The check above counted, then the request went on awaiting, and only\n * then created — and every await is a yield, so N concurrent creates\n * each read the same pre-count, each found room, and each landed.\n * Nothing re-counts afterwards, so the extra datasets were permanent and\n * billed nothing. `tx.get` on the aggregate takes a pessimistic lock on\n * the documents the query matched, so the loser of a race retries,\n * re-reads the higher count and is refused.\n *\n * The ORG document is deliberately read outside: the plan and the\n * purchased add-ons are the other input to this decision, but neither is\n * client-writable, so locking the org doc on every dataset create would\n * buy contention rather than correctness. That is the AGL-2369 lesson\n * applied, not skipped — there the routing map *was* client-writable and\n * *did* change the count.\n *\n * The refusal comes back as data and is rendered outside: building a\n * response inside a body that can run several times reads as if the\n * transaction were a place effects happen.\n */\n const refusal = await firestore.runTransaction<string | null>(\n async (tx) => {\n const live = (await tx.get(datasetsRef.count())).data().count\n const authoritative = checkDatasetQuota(org, live)\n if (!authoritative.allowed) return overDatasetQuota(authoritative)\n tx.create(datasetsRef.doc(id), {\n displayName,\n fields,\n ...(model ? { model } : {}),\n // The scope decided above. Stamping SOMETHING is mandatory: an\n // `array-contains-any` matches nothing on a doc missing the field,\n // so an unstamped dataset renders on no site at all (AGL-1044).\n //\n // Through `newResourceScopeFields` since AGL-1484: AGL-1478 built\n // that helper as the type-level gate for exactly the collections\n // with no document constructor of their own — `datasets` named\n // first among them — and then only the marketplace fork used it,\n // while the two ordinary dataset creates went on writing the field\n // by hand. A required argument that three of four callers bypass\n // is a convention, not a guarantee.\n ...newResourceScopeFields(visibleTo),\n createdAt: Timestamp.now(),\n })\n return null\n },\n )\n if (refusal) return Response.json({ error: refusal }, { status: 403 })\n return Response.json({ ok: true, id }, { status: 200 })\n }\n\n if (\n action === 'create-record' ||\n action === 'import-records' ||\n action === 'announce-records' ||\n action === 'add-address-field'\n ) {\n const datasetId = String(body?.datasetId ?? '')\n if (!datasetId) {\n return Response.json({ error: 'Missing datasetId' }, { status: 400 })\n }\n const datasetRef = orgRef.collection('datasets').doc(datasetId)\n const datasetSnapshot = await datasetRef.get()\n if (!datasetSnapshot.exists) {\n return Response.json({ error: 'Unknown dataset' }, { status: 404 })\n }\n /**\n * The caller has to be able to SEE the dataset they add rows to.\n *\n * The role and `data.manage` gates above are about the org, and a member\n * scoped to one site passes both. The rules hold every record update and\n * delete to the parent dataset being visible (`parentDatasetVisible()`),\n * and refuse client record creates outright, so this route is the one\n * place a member's create can be held to the same condition. Without it,\n * a collaborator on one client's site could add rows, by id alone, to an\n * internal dataset shared only with other sites.\n *\n * The Admin SDK evaluates no rules, so `memberCanSee` IS the enforcement\n * here, as on the export route beside this one. Answered with the same\n * 404 as a dataset that does not exist: whether one outside the caller's\n * reach exists is not theirs to learn either.\n */\n if (\n decoded['staff'] !== true &&\n !memberCanSee(\n member,\n (datasetSnapshot.data() as { visibleTo?: string[] } | undefined)\n ?.visibleTo,\n )\n ) {\n return Response.json({ error: 'Unknown dataset' }, { status: 404 })\n }\n /**\n * A record the BROWSER wrote, announcing itself (AGL-3113).\n *\n * Record edits and deletes stay client-direct on purpose — they consume\n * no quota, so AGL-473 left them out of the server leg — which also left\n * them with nothing to announce from. They are the same staleness as a\n * create: a page listing the rows goes on showing the old one.\n *\n * A drop, and only a drop. It moves no pointer, writes nothing and\n * registers no route, so it grants nothing the rules withhold — and it\n * is gated by the same org membership and the same `memberCanSee` the\n * create is, which is what stops a collaborator on one site from\n * learning that another site's dataset exists.\n */\n if (action === 'announce-records') {\n const announced = await announceDatasetChange({\n firestore,\n orgId,\n datasetId,\n })\n return Response.json({ ok: true, announced }, { status: 200 })\n }\n const model = effectiveDatasetModel(datasetSnapshot.data() as any)\n /**\n * Gives the dataset a page address (AGL-3475): a \"Page address\" field\n * that fills in from `sourceField`, and an address on every record that\n * has none yet, unique within the dataset (`roofing`, `roofing-2`).\n *\n * Server-side because it writes every record, and each write has to\n * carry the filter fields the record template's lookup reads — the\n * address is found through `filterValues`, so a record written without\n * them would have a page nobody can reach. A dataset that already has\n * an address field keeps it: the call fills that field's empty\n * addresses instead of adding a second one.\n */\n if (action === 'add-address-field') {\n const sourceField = String(body?.sourceField ?? '').trim()\n const source = model.fields[sourceField]\n if (!source || source.type !== 'text' || isRecordAddressField(source)) {\n return Response.json(\n { error: 'Pick a text field to fill page addresses from' },\n { status: 400 },\n )\n }\n const held = recordAddressFieldIds(model)[0]\n const fieldId = held ?? defaultDatasetFieldId('slug', new Set(model.order))\n const nextModel = held\n ? model\n : {\n fields: {\n ...model.fields,\n [fieldId]: {\n name: 'Page address',\n type: 'text' as const,\n customType: RECORD_PAGE_ADDRESS_FIELD_TYPE,\n slugFrom: sourceField,\n },\n },\n order: [...model.order, fieldId],\n }\n if (!held) {\n await datasetRef.update({ model: nextModel, updatedAt: Timestamp.now() })\n }\n const snapshot = await datasetRef\n .collection('records')\n .orderBy(FieldPath.documentId())\n .limit(ADDRESS_FILL_MAX + 1)\n .get()\n const docs = snapshot.docs.slice(0, ADDRESS_FILL_MAX)\n const valuesOf = (doc: FirebaseFirestore.QueryDocumentSnapshot) =>\n (doc.get('values') ?? {}) as Record<string, unknown>\n const taken = docs\n .map((doc) => recordAddressOf(valuesOf(doc), fieldId))\n .filter(Boolean)\n const assigned = uniqueRecordAddresses(\n docs\n .filter((doc) => !recordAddressOf(valuesOf(doc), fieldId))\n .map((doc) => ({ id: doc.id, source: valuesOf(doc)[sourceField] })),\n taken,\n )\n const writes = docs.filter((doc) => assigned.has(doc.id))\n for (let start = 0; start < writes.length; start += IMPORT_CHUNK) {\n const batch = firestore.batch()\n for (const doc of writes.slice(start, start + IMPORT_CHUNK)) {\n const values = { ...valuesOf(doc), [fieldId]: assigned.get(doc.id) }\n batch.update(doc.ref, {\n values,\n ...datasetIntegrityUpdate(nextModel, values, FieldValue.delete()),\n updatedAt: Timestamp.now(),\n })\n }\n await batch.commit()\n }\n if (writes.length || !held) {\n await announceDatasetChange({ firestore, orgId, datasetId })\n }\n return Response.json(\n {\n ok: true,\n fieldId,\n filled: writes.length,\n truncated: snapshot.docs.length > ADDRESS_FILL_MAX,\n },\n { status: 200 },\n )\n }\n // Before either validation below: a plugin's field validator only runs\n // once its plugin's server entry has registered it.\n await ensureDeclaredCustomFieldTypes(model)\n const recordsRef = datasetRef.collection('records')\n const recordCount = (await recordsRef.count().get()).data().count\n const overRecordQuota = (limit: number) =>\n `Record limit reached (${limit}) — upgrade in Billing`\n\n if (action === 'create-record') {\n const coerced = fillRecordAddresses(\n model,\n coerceDocumentValues(model, body?.values ?? {}),\n )\n const errors = validateDocument(model, coerced)\n if (Object.keys(errors).length) {\n return Response.json({ error: 'Record failed validation', errors }, { status: 400 })\n }\n // The fast refusal. NOT the enforcement point — that is the\n // transaction below — but it keeps the refusal ORDER on this route\n // intact: a caller over BOTH the row cap and the byte band is told\n // about the rows, as they always were.\n const quota = checkQuota(org, 'recordsPerDataset', recordCount)\n if (!quota.allowed) {\n return Response.json({\n error: overRecordQuota(quota.limit),\n }, { status: 403 })\n }\n // Bytes, not rows (AGL-2163). Before the write, like every other\n // quota on this route.\n const storageRefusal = await refuseIfDataStorageBlocked(org, orgRef)\n if (storageRefusal) return storageRefusal\n const id = createResourceUid()\n /**\n * THE ENFORCEMENT POINT (AGL-2371, the AGL-2231 treatment): the count,\n * the decision and the create in ONE transaction.\n *\n * The check above counted, then `refuseIfDataStorageBlocked` awaited,\n * and only then did the row land. Every await is a yield, so N\n * concurrent creates each read the same pre-count, each found room,\n * and each landed — permanently, since nothing re-counts afterwards. A\n * free-with-rows-granted org's thousand rows became twenty thousand by\n * sending twenty requests at once.\n *\n * `tx.get` on the aggregate takes a pessimistic lock on the documents\n * the query matched, so the loser retries, re-reads the higher count\n * and is refused. `order` is taken from the transaction's own count\n * for the same reason: the pre-count is what made two concurrent rows\n * share an order.\n *\n * The BYTE gate stays outside. It is a different quota with its own\n * reads, it is not a function of the rows this transaction writes, and\n * re-running it on every retry attempt would put a multi-document read\n * inside a lock.\n */\n const refusal = await firestore.runTransaction<string | null>(\n async (tx) => {\n const live = (await tx.get(recordsRef.count())).data().count\n const authoritative = checkQuota(org, 'recordsPerDataset', live)\n if (!authoritative.allowed) return overRecordQuota(authoritative.limit)\n tx.create(recordsRef.doc(id), {\n values: coerced,\n // The integrity index the delete check queries. Written on the\n // same write as the values it describes — an index that lags\n // them reports a reference the record no longer holds, or\n // misses one it does.\n ...datasetIntegrityFields(model, coerced),\n order: live,\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n })\n return null\n },\n )\n if (refusal) return Response.json({ error: refusal }, { status: 403 })\n // The record is stored; now the pages that repeat over it are told\n // (AGL-3113). Awaited so the site is already serving the new row by the\n // time the editor's save returns, and reported so a refused drop can\n // say the live pages are behind rather than leaving it to be noticed.\n const announced = await announceDatasetChange({\n firestore,\n orgId,\n datasetId,\n })\n return Response.json({ ok: true, id, announced }, { status: 200 })\n }\n\n // import-records: the console sends only the NEW rows (updates to\n // existing ids stay client-direct — they don't consume quota).\n const rows = Array.isArray(body?.records)\n ? (body.records as Array<{ values?: unknown }>)\n : []\n if (rows.length === 0) {\n return Response.json({ error: 'No records to import' }, { status: 400 })\n }\n const overImportQuota = (limit: number, needed: number) =>\n `This import needs ${needed} record slots — your plan ` +\n `allows ${limit} per dataset. See Billing to upgrade.`\n // The fast refusal, on the whole import. NOT the enforcement point —\n // that is the per-chunk transaction below — but it is what answers an\n // import that never fitted, before a single row is written.\n const quota = checkQuota(\n org,\n 'recordsPerDataset',\n recordCount + rows.length - 1,\n )\n if (!quota.allowed) {\n return Response.json({\n error: overImportQuota(quota.limit, rows.length),\n }, { status: 403 })\n }\n // Bytes, not rows (AGL-2163). An import is the path that moves the\n // most bytes in one request, so leaving it out would have left the\n // enforcement exactly where the customer does not meet it.\n const importStorageRefusal = await refuseIfDataStorageBlocked(org, orgRef)\n if (importStorageRefusal) return importStorageRefusal\n const prepared = rows.map((row, index) => {\n const coerced = fillRecordAddresses(\n model,\n coerceDocumentValues(\n model,\n (row?.values ?? {}) as Record<string, unknown>,\n ),\n )\n const errors = validateDocument(model, coerced)\n return { index, coerced, valid: Object.keys(errors).length === 0 }\n })\n const invalid = prepared.filter((row) => !row.valid)\n if (invalid.length) {\n return Response.json({\n error: `${invalid.length} rows failed validation`,\n rows: invalid.map((row) => row.index),\n }, { status: 400 })\n }\n /**\n * THE ENFORCEMENT POINT (AGL-2371): each chunk counts, decides and\n * writes inside ONE transaction.\n *\n * The import used to count once, decide once, and then commit a series\n * of `WriteBatch`es. A batch is atomic but NOT conditional on a read\n * taken before it, so two concurrent imports each priced themselves off\n * the same pre-count and both landed in full — the create leg's defect,\n * multiplied by the batch size.\n *\n * It cannot be ONE transaction for every import the way the single-row\n * create can: `recordsPerDataset` runs to a million, and a transaction\n * carries at most 500 writes. So the chunk is the unit, and each chunk's\n * count is re-read under the lock and re-checked against the plan. The\n * cap therefore holds ABSOLUTELY at every size — what a race can cost is\n * a partial import, not an overshoot, and a partial import was already\n * possible the moment a mid-import batch could fail.\n *\n * The ids are minted before the transaction so a retry re-creates the\n * SAME documents rather than a second copy of the chunk.\n *\n * This is not AGL-2370's problem in miniature. There the whole bundle\n * must land or none of it, so chunking cannot be the unit and a lease is\n * needed; here rows are independent and \"fewer rows than asked for\" is a\n * reportable outcome.\n */\n const ids: string[] = []\n let refusedAt: string | null = null\n for (\n let start = 0;\n start < prepared.length && refusedAt === null;\n start += IMPORT_CHUNK\n ) {\n const chunk = prepared.slice(start, start + IMPORT_CHUNK)\n const chunkIds = chunk.map(() => createResourceUid())\n refusedAt = await firestore.runTransaction<string | null>(async (tx) => {\n const live = (await tx.get(recordsRef.count())).data().count\n const authoritative = checkQuota(\n org,\n 'recordsPerDataset',\n live + chunk.length - 1,\n )\n if (!authoritative.allowed) {\n return overImportQuota(authoritative.limit, rows.length)\n }\n chunk.forEach((row, offset) => {\n tx.create(recordsRef.doc(chunkIds[offset]), {\n values: row.coerced,\n ...datasetIntegrityFields(model, row.coerced),\n order: live + offset,\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n })\n })\n return null\n })\n if (refusedAt === null) ids.push(...chunkIds)\n }\n /**\n * ONE announce for the whole import, after the loop (AGL-3113).\n *\n * Not per chunk and certainly not per row: a thousand rows arrive as one\n * import and make the same pages stale once, so the pages are worked out\n * once and dropped once. The refusal path announces too — rows that\n * landed before the quota bit are live records, and leaving them out of\n * the refresh would make a partial import look like no import at all.\n */\n if (ids.length) {\n await announceDatasetChange({ firestore, orgId, datasetId })\n }\n if (refusedAt !== null) {\n // What landed is reported alongside the refusal. Rows are independent,\n // so silently discarding the created ids would tell the caller nothing\n // was written while the dataset says otherwise.\n return Response.json(\n { error: refusedAt, ids, created: ids.length },\n { status: 403 },\n )\n }\n return Response.json({ ok: true, ids, created: ids.length }, { status: 200 })\n }\n\n return Response.json({ error: 'Unknown action' }, { status: 400 })\n } catch (error) {\n // A refused credential is a 401, not a fault of ours (AGL-1993); anything\n // else keeps the answer below.\n if (isRefusedIdToken(error)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n console.error(error)\n return Response.json({ error: 'Dataset operation failed' }, { status: 500 })\n }\n}\n"],"names":["defaultScopeForNewResource","newResourceScopeFields","pluginRequestFromWeb","checkDatasetQuota","checkEntitlement","checkQuota","createResourceUid","ensureDeclaredCustomFieldTypes","memberCanSee","emailUnverifiedResponse","firebaseAdmin","dataStorageRefusal","isImpersonationSession","isServerReleaseFlagOnForOrg","lockdownRefusal","memberHasOrgPermission","resolveOrgIdForHost","resolveOrgMembership","isRefusedIdToken","FieldPath","FieldValue","Timestamp","announceDatasetRecords","announceDatasetChange","coerceDocumentValues","datasetIntegrityFields","datasetIntegrityUpdate","effectiveDatasetModel","validateDocument","defaultDatasetFieldId","RECORD_PAGE_ADDRESS_FIELD_TYPE","fillRecordAddresses","isRecordAddressField","recordAddressFieldIds","recordAddressOf","uniqueRecordAddresses","refuseIfDataStorageBlocked","org","orgRef","refusal","Response","json","error","basis","includedMb","status","WRITER_ROLES","Set","IMPORT_CHUNK","ADDRESS_FILL_MAX","datasetsHandler","request","headers","method","body","rawHeaders","authorization","idToken","startsWith","slice","length","undefined","orgId","String","action","decoded","app","auth","verifyIdToken","email_verified","membership","uid","member","has","role","firestore","collection","doc","orgSnapshot","get","exists","data","locked","staff","displayName","trim","fields","Array","isArray","map","field","hostId","includes","visibleTo","defaultResourceScope","datasetsRef","overDatasetQuota","quota","upgradeRequired","limit","addonPriceUsd","count","allowed","id","model","JSON","stringify","runTransaction","tx","live","authoritative","create","createdAt","now","ok","datasetSnapshot","datasetId","datasetRef","announced","sourceField","source","type","held","fieldId","order","nextModel","name","customType","slugFrom","update","updatedAt","snapshot","orderBy","documentId","docs","valuesOf","taken","filter","Boolean","assigned","writes","start","batch","values","ref","delete","commit","filled","truncated","recordsRef","recordCount","overRecordQuota","coerced","errors","Object","keys","storageRefusal","rows","records","overImportQuota","needed","importStorageRefusal","prepared","row","index","valid","invalid","ids","refusedAt","chunk","chunkIds","forEach","offset","push","created","console"],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * Resolves a dataset by its human name: console-created docs store it as
19
+ * `displayName` (AGL-536); the `name` fallback covers pre-migration docs.
20
+ *
21
+ * `scope` narrows the candidates to what one host may see (AGL-1039). It
22
+ * is applied INSIDE the query rather than to the result, which is what
23
+ * makes the collision case come out right: when a host-scoped dataset and
24
+ * an org-wide one share a display name, an unscoped `limit(1)` would pick
25
+ * one arbitrarily and a post-filter would then reject it — reading as
26
+ * "no such dataset" when a perfectly visible one exists.
27
+ */
28
+ export declare function findDatasetByName(datasetsRef: FirebaseFirestore.CollectionReference, datasetName: string, scope?: (query: FirebaseFirestore.Query) => FirebaseFirestore.Query): Promise<FirebaseFirestore.QueryDocumentSnapshot | undefined>;
29
+ /**
30
+ * Id-first dataset resolution (AGL-556): a `datasetId` binding is a
31
+ * direct doc get — display names never enter into it, so renamed
32
+ * datasets keep receiving records. When the id is absent or doesn't
33
+ * resolve, the human-name query (AGL-536 semantics) is the legacy
34
+ * fallback. Returns undefined when neither resolves; callers still own
35
+ * the `deletedAt` check.
36
+ */
37
+ export declare function resolveDatasetDoc(datasetsRef: FirebaseFirestore.CollectionReference, binding: {
38
+ datasetId?: string | null;
39
+ datasetName?: string | null;
40
+ }, hostId: string): Promise<FirebaseFirestore.DocumentSnapshot | undefined>;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import * as Aglyn from "@aglyn/aglyn/server";
17
+ /**
18
+ * Resolves a dataset by its human name: console-created docs store it as
19
+ * `displayName` (AGL-536); the `name` fallback covers pre-migration docs.
20
+ *
21
+ * `scope` narrows the candidates to what one host may see (AGL-1039). It
22
+ * is applied INSIDE the query rather than to the result, which is what
23
+ * makes the collision case come out right: when a host-scoped dataset and
24
+ * an org-wide one share a display name, an unscoped `limit(1)` would pick
25
+ * one arbitrarily and a post-filter would then reject it — reading as
26
+ * "no such dataset" when a perfectly visible one exists.
27
+ */ export async function findDatasetByName(datasetsRef, datasetName, scope) {
28
+ const narrow = scope != null ? scope : (query)=>query;
29
+ const byDisplayName = await narrow(datasetsRef.where('displayName', '==', datasetName)).limit(1).get();
30
+ if (!byDisplayName.empty) return byDisplayName.docs[0];
31
+ return (await narrow(datasetsRef.where('name', '==', datasetName)).limit(1).get()).docs[0];
32
+ }
33
+ /**
34
+ * Id-first dataset resolution (AGL-556): a `datasetId` binding is a
35
+ * direct doc get — display names never enter into it, so renamed
36
+ * datasets keep receiving records. When the id is absent or doesn't
37
+ * resolve, the human-name query (AGL-536 semantics) is the legacy
38
+ * fallback. Returns undefined when neither resolves; callers still own
39
+ * the `deletedAt` check.
40
+ */ export async function resolveDatasetDoc(datasetsRef, binding, hostId) {
41
+ var _datasetsRef_parent_parent, _datasetsRef_parent, _binding_datasetId, _binding_datasetName;
42
+ // `hostId` is required rather than optional on purpose (AGL-1039): every
43
+ // caller here writes records into whatever comes back, and an optional
44
+ // scope is one a future caller forgets to pass. The Admin SDK ignores
45
+ // rules, so this is the only thing standing between a client site and
46
+ // another client's dataset.
47
+ const orgScoped = ((_datasetsRef_parent = datasetsRef.parent) == null ? void 0 : (_datasetsRef_parent_parent = _datasetsRef_parent.parent) == null ? void 0 : _datasetsRef_parent_parent.id) === 'orgs';
48
+ const datasetId = (_binding_datasetId = binding.datasetId) == null ? void 0 : _binding_datasetId.trim();
49
+ if (datasetId) {
50
+ const datasetDoc = await datasetsRef.doc(datasetId).get();
51
+ if (datasetDoc.exists && (!orgScoped || Aglyn.visibleToHost(datasetDoc.get('visibleTo'), hostId))) {
52
+ return datasetDoc;
53
+ }
54
+ // A hit this host cannot see is NOT a reason to fall through to the
55
+ // name lookup — that would answer "which dataset is called X" for a
56
+ // binding that already named a specific one.
57
+ if (datasetDoc.exists) return undefined;
58
+ }
59
+ const datasetName = (_binding_datasetName = binding.datasetName) == null ? void 0 : _binding_datasetName.trim();
60
+ if (!datasetName) return undefined;
61
+ return findDatasetByName(datasetsRef, datasetName, orgScoped ? (query)=>query.where('visibleTo', 'array-contains-any', Aglyn.scopeTokensForHost(hostId)) : undefined);
62
+ }
63
+
64
+ //# sourceMappingURL=resolve-dataset.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/server/resolve-dataset.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\n\n/**\n * Resolves a dataset by its human name: console-created docs store it as\n * `displayName` (AGL-536); the `name` fallback covers pre-migration docs.\n *\n * `scope` narrows the candidates to what one host may see (AGL-1039). It\n * is applied INSIDE the query rather than to the result, which is what\n * makes the collision case come out right: when a host-scoped dataset and\n * an org-wide one share a display name, an unscoped `limit(1)` would pick\n * one arbitrarily and a post-filter would then reject it — reading as\n * \"no such dataset\" when a perfectly visible one exists.\n */\nexport async function findDatasetByName(\n datasetsRef: FirebaseFirestore.CollectionReference,\n datasetName: string,\n scope?: (query: FirebaseFirestore.Query) => FirebaseFirestore.Query,\n): Promise<FirebaseFirestore.QueryDocumentSnapshot | undefined> {\n const narrow = scope ?? ((query: FirebaseFirestore.Query) => query)\n const byDisplayName = await narrow(\n datasetsRef.where('displayName', '==', datasetName),\n )\n .limit(1)\n .get()\n if (!byDisplayName.empty) return byDisplayName.docs[0]\n return (\n await narrow(datasetsRef.where('name', '==', datasetName)).limit(1).get()\n ).docs[0]\n}\n\n/**\n * Id-first dataset resolution (AGL-556): a `datasetId` binding is a\n * direct doc get — display names never enter into it, so renamed\n * datasets keep receiving records. When the id is absent or doesn't\n * resolve, the human-name query (AGL-536 semantics) is the legacy\n * fallback. Returns undefined when neither resolves; callers still own\n * the `deletedAt` check.\n */\nexport async function resolveDatasetDoc(\n datasetsRef: FirebaseFirestore.CollectionReference,\n binding: { datasetId?: string | null; datasetName?: string | null },\n hostId: string,\n): Promise<FirebaseFirestore.DocumentSnapshot | undefined> {\n // `hostId` is required rather than optional on purpose (AGL-1039): every\n // caller here writes records into whatever comes back, and an optional\n // scope is one a future caller forgets to pass. The Admin SDK ignores\n // rules, so this is the only thing standing between a client site and\n // another client's dataset.\n const orgScoped = datasetsRef.parent?.parent?.id === 'orgs'\n const datasetId = binding.datasetId?.trim()\n if (datasetId) {\n const datasetDoc = await datasetsRef.doc(datasetId).get()\n if (\n datasetDoc.exists &&\n (!orgScoped ||\n Aglyn.visibleToHost(datasetDoc.get('visibleTo'), hostId))\n ) {\n return datasetDoc\n }\n // A hit this host cannot see is NOT a reason to fall through to the\n // name lookup — that would answer \"which dataset is called X\" for a\n // binding that already named a specific one.\n if (datasetDoc.exists) return undefined\n }\n const datasetName = binding.datasetName?.trim()\n if (!datasetName) return undefined\n return findDatasetByName(\n datasetsRef,\n datasetName,\n orgScoped\n ? (query) =>\n query.where(\n 'visibleTo',\n 'array-contains-any',\n Aglyn.scopeTokensForHost(hostId),\n )\n : undefined,\n )\n}\n"],"names":["Aglyn","findDatasetByName","datasetsRef","datasetName","scope","narrow","query","byDisplayName","where","limit","get","empty","docs","resolveDatasetDoc","binding","hostId","orgScoped","parent","id","datasetId","trim","datasetDoc","doc","exists","visibleToHost","undefined","scopeTokensForHost"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,sBAAqB;AAE5C;;;;;;;;;;CAUC,GACD,OAAO,eAAeC,kBACpBC,WAAkD,EAClDC,WAAmB,EACnBC,KAAmE;IAEnE,MAAMC,SAASD,gBAAAA,QAAU,CAACE,QAAmCA;IAC7D,MAAMC,gBAAgB,MAAMF,OAC1BH,YAAYM,KAAK,CAAC,eAAe,MAAML,cAEtCM,KAAK,CAAC,GACNC,GAAG;IACN,IAAI,CAACH,cAAcI,KAAK,EAAE,OAAOJ,cAAcK,IAAI,CAAC,EAAE;IACtD,OAAO,AACL,CAAA,MAAMP,OAAOH,YAAYM,KAAK,CAAC,QAAQ,MAAML,cAAcM,KAAK,CAAC,GAAGC,GAAG,EAAC,EACxEE,IAAI,CAAC,EAAE;AACX;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeC,kBACpBX,WAAkD,EAClDY,OAAmE,EACnEC,MAAc;QAOIb,4BAAAA,qBACAY,oBAeEA;IArBpB,yEAAyE;IACzE,uEAAuE;IACvE,sEAAsE;IACtE,sEAAsE;IACtE,4BAA4B;IAC5B,MAAME,YAAYd,EAAAA,sBAAAA,YAAYe,MAAM,sBAAlBf,6BAAAA,oBAAoBe,MAAM,qBAA1Bf,2BAA4BgB,EAAE,MAAK;IACrD,MAAMC,aAAYL,qBAAAA,QAAQK,SAAS,qBAAjBL,mBAAmBM,IAAI;IACzC,IAAID,WAAW;QACb,MAAME,aAAa,MAAMnB,YAAYoB,GAAG,CAACH,WAAWT,GAAG;QACvD,IACEW,WAAWE,MAAM,IAChB,CAAA,CAACP,aACAhB,MAAMwB,aAAa,CAACH,WAAWX,GAAG,CAAC,cAAcK,OAAM,GACzD;YACA,OAAOM;QACT;QACA,oEAAoE;QACpE,oEAAoE;QACpE,6CAA6C;QAC7C,IAAIA,WAAWE,MAAM,EAAE,OAAOE;IAChC;IACA,MAAMtB,eAAcW,uBAAAA,QAAQX,WAAW,qBAAnBW,qBAAqBM,IAAI;IAC7C,IAAI,CAACjB,aAAa,OAAOsB;IACzB,OAAOxB,kBACLC,aACAC,aACAa,YACI,CAACV,QACCA,MAAME,KAAK,CACT,aACA,sBACAR,MAAM0B,kBAAkB,CAACX,WAE7BU;AAER"}
@@ -16,8 +16,9 @@
16
16
  */
17
17
  /**
18
18
  * Console API: the organization's datasets — creating a dataset and its
19
- * records within the plan (`POST /api/orgs/datasets`), and the complete export
20
- * of one (`GET /api/orgs/datasets/export`). Served by the console's plugin
19
+ * records within the plan (`POST /api/orgs/datasets`) — and a site's record
20
+ * templates (`POST /api/hosts/record-pages`). A dataset's import and export
21
+ * run on the platform's transfer routes (`/api/transfer/*`, AGL-3530). Served by the console's plugin
21
22
  * dispatcher, at the addresses the console always answered them on.
22
23
  */
23
24
  export declare function registerDataConsoleApi(): void;
package/src/lib/server.js CHANGED
@@ -14,12 +14,11 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { registerPluginApiRoute } from "@aglyn/aglyn/server";
17
- import { datasetsExportHandler } from "./server/datasets-export-route.js";
18
17
  import { datasetsHandler } from "./server/datasets-route.js";
18
+ import { recordPagesHandler } from "./record-pages/record-pages-route.js";
19
19
  /**
20
20
  * The organization a datasets request is for, read from the request the way
21
- * each handler reads it — the JSON body's `orgId` on a write, `?orgId=` on an
22
- * export. Datasets belong to the organization and name no site, so without
21
+ * the handler reads it — the JSON body's `orgId` (or `?orgId=`). Datasets belong to the organization and name no site, so without
23
22
  * this the dispatcher's release gate would read the request as anonymous and
24
23
  * refuse it under a partial rollout of the data store.
25
24
  */ const orgSubject = async (request)=>{
@@ -35,8 +34,9 @@ import { datasetsHandler } from "./server/datasets-route.js";
35
34
  };
36
35
  /**
37
36
  * Console API: the organization's datasets — creating a dataset and its
38
- * records within the plan (`POST /api/orgs/datasets`), and the complete export
39
- * of one (`GET /api/orgs/datasets/export`). Served by the console's plugin
37
+ * records within the plan (`POST /api/orgs/datasets`) — and a site's record
38
+ * templates (`POST /api/hosts/record-pages`). A dataset's import and export
39
+ * run on the platform's transfer routes (`/api/transfer/*`, AGL-3530). Served by the console's plugin
40
40
  * dispatcher, at the addresses the console always answered them on.
41
41
  */ export function registerDataConsoleApi() {
42
42
  registerPluginApiRoute('orgs/datasets', {
@@ -44,10 +44,9 @@ import { datasetsHandler } from "./server/datasets-route.js";
44
44
  }, {
45
45
  subject: orgSubject
46
46
  });
47
- registerPluginApiRoute('orgs/datasets/export', {
48
- web: datasetsExportHandler
49
- }, {
50
- subject: orgSubject
47
+ // A site's record templates (AGL-3475): `POST /api/hosts/record-pages`.
48
+ registerPluginApiRoute('hosts/record-pages', {
49
+ web: recordPagesHandler
51
50
  });
52
51
  }
53
52
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/data/src/lib/server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n registerPluginApiRoute,\n type PluginApiSubjectResolver,\n} from '@aglyn/aglyn/server'\nimport { datasetsExportHandler } from './server/datasets-export-route'\nimport { datasetsHandler } from './server/datasets-route'\n\n/**\n * The organization a datasets request is for, read from the request the way\n * each handler reads it — the JSON body's `orgId` on a write, `?orgId=` on an\n * export. Datasets belong to the organization and name no site, so without\n * this the dispatcher's release gate would read the request as anonymous and\n * refuse it under a partial rollout of the data store.\n */\nconst orgSubject: PluginApiSubjectResolver = async (request) => {\n const fromQuery = new URL(request.url).searchParams.get('orgId')\n if (fromQuery) return { orgId: fromQuery }\n if (request.method === 'GET' || request.method === 'HEAD') return null\n const body = (await request.json().catch(() => null)) as {\n orgId?: unknown\n } | null\n return typeof body?.orgId === 'string' && body.orgId\n ? { orgId: body.orgId }\n : null\n}\n\n/**\n * Console API: the organization's datasets — creating a dataset and its\n * records within the plan (`POST /api/orgs/datasets`), and the complete export\n * of one (`GET /api/orgs/datasets/export`). Served by the console's plugin\n * dispatcher, at the addresses the console always answered them on.\n */\nexport function registerDataConsoleApi(): void {\n registerPluginApiRoute('orgs/datasets', { web: datasetsHandler }, {\n subject: orgSubject,\n })\n registerPluginApiRoute(\n 'orgs/datasets/export',\n { web: datasetsExportHandler },\n { subject: orgSubject },\n )\n}\n"],"names":["registerPluginApiRoute","datasetsExportHandler","datasetsHandler","orgSubject","request","fromQuery","URL","url","searchParams","get","orgId","method","body","json","catch","registerDataConsoleApi","web","subject"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,sBAAsB,QAEjB,sBAAqB;AAC5B,SAASC,qBAAqB,QAAQ,oCAAgC;AACtE,SAASC,eAAe,QAAQ,6BAAyB;AAEzD;;;;;;CAMC,GACD,MAAMC,aAAuC,OAAOC;IAClD,MAAMC,YAAY,IAAIC,IAAIF,QAAQG,GAAG,EAAEC,YAAY,CAACC,GAAG,CAAC;IACxD,IAAIJ,WAAW,OAAO;QAAEK,OAAOL;IAAU;IACzC,IAAID,QAAQO,MAAM,KAAK,SAASP,QAAQO,MAAM,KAAK,QAAQ,OAAO;IAClE,MAAMC,OAAQ,MAAMR,QAAQS,IAAI,GAAGC,KAAK,CAAC,IAAM;IAG/C,OAAO,QAAOF,wBAAAA,KAAMF,KAAK,MAAK,YAAYE,KAAKF,KAAK,GAChD;QAAEA,OAAOE,KAAKF,KAAK;IAAC,IACpB;AACN;AAEA;;;;;CAKC,GACD,OAAO,SAASK;IACdf,uBAAuB,iBAAiB;QAAEgB,KAAKd;IAAgB,GAAG;QAChEe,SAASd;IACX;IACAH,uBACE,wBACA;QAAEgB,KAAKf;IAAsB,GAC7B;QAAEgB,SAASd;IAAW;AAE1B"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/data/src/lib/server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n registerPluginApiRoute,\n type PluginApiSubjectResolver,\n} from '@aglyn/aglyn/server'\nimport { datasetsHandler } from './server/datasets-route'\nimport { recordPagesHandler } from './record-pages/record-pages-route'\n\n/**\n * The organization a datasets request is for, read from the request the way\n * the handler reads it — the JSON body's `orgId` (or `?orgId=`). Datasets belong to the organization and name no site, so without\n * this the dispatcher's release gate would read the request as anonymous and\n * refuse it under a partial rollout of the data store.\n */\nconst orgSubject: PluginApiSubjectResolver = async (request) => {\n const fromQuery = new URL(request.url).searchParams.get('orgId')\n if (fromQuery) return { orgId: fromQuery }\n if (request.method === 'GET' || request.method === 'HEAD') return null\n const body = (await request.json().catch(() => null)) as {\n orgId?: unknown\n } | null\n return typeof body?.orgId === 'string' && body.orgId\n ? { orgId: body.orgId }\n : null\n}\n\n/**\n * Console API: the organization's datasets — creating a dataset and its\n * records within the plan (`POST /api/orgs/datasets`) — and a site's record\n * templates (`POST /api/hosts/record-pages`). A dataset's import and export\n * run on the platform's transfer routes (`/api/transfer/*`, AGL-3530). Served by the console's plugin\n * dispatcher, at the addresses the console always answered them on.\n */\nexport function registerDataConsoleApi(): void {\n registerPluginApiRoute('orgs/datasets', { web: datasetsHandler }, {\n subject: orgSubject,\n })\n // A site's record templates (AGL-3475): `POST /api/hosts/record-pages`.\n registerPluginApiRoute('hosts/record-pages', { web: recordPagesHandler })\n}\n"],"names":["registerPluginApiRoute","datasetsHandler","recordPagesHandler","orgSubject","request","fromQuery","URL","url","searchParams","get","orgId","method","body","json","catch","registerDataConsoleApi","web","subject"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,sBAAsB,QAEjB,sBAAqB;AAC5B,SAASC,eAAe,QAAQ,6BAAyB;AACzD,SAASC,kBAAkB,QAAQ,uCAAmC;AAEtE;;;;;CAKC,GACD,MAAMC,aAAuC,OAAOC;IAClD,MAAMC,YAAY,IAAIC,IAAIF,QAAQG,GAAG,EAAEC,YAAY,CAACC,GAAG,CAAC;IACxD,IAAIJ,WAAW,OAAO;QAAEK,OAAOL;IAAU;IACzC,IAAID,QAAQO,MAAM,KAAK,SAASP,QAAQO,MAAM,KAAK,QAAQ,OAAO;IAClE,MAAMC,OAAQ,MAAMR,QAAQS,IAAI,GAAGC,KAAK,CAAC,IAAM;IAG/C,OAAO,QAAOF,wBAAAA,KAAMF,KAAK,MAAK,YAAYE,KAAKF,KAAK,GAChD;QAAEA,OAAOE,KAAKF,KAAK;IAAC,IACpB;AACN;AAEA;;;;;;CAMC,GACD,OAAO,SAASK;IACdf,uBAAuB,iBAAiB;QAAEgB,KAAKf;IAAgB,GAAG;QAChEgB,SAASd;IACX;IACA,wEAAwE;IACxEH,uBAAuB,sBAAsB;QAAEgB,KAAKd;IAAmB;AACzE"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginSiteBundleSectionPackage, SiteBundleItem, SiteBundleItemReference } from '@aglyn/aglyn/plugin-manager/plugin-site-bundle';
18
+ /**
19
+ * A site's datasets as site package items (AGL-3533).
20
+ *
21
+ * A dataset names other datasets through its model's `reference` fields —
22
+ * the dataset a reference column points into. Those are the dependencies a
23
+ * package lists, and the ids a dataset kept beside an existing one (or mapped
24
+ * onto one the site already holds) has to move. Records keep their own ids
25
+ * under whichever dataset they land in, so the record ids a reference cell
26
+ * holds still resolve and nothing inside a record is rewritten.
27
+ *
28
+ * Pure and light: the declarations register these at boot, ahead of the
29
+ * section's Admin-SDK half, which loads on first use.
30
+ */
31
+ /** The package kind a dataset is listed as, as `plugins.config.json` declares it. */
32
+ export declare const DATASET_PACKAGE_KIND = "dataset";
33
+ /** The datasets this one's reference fields point into. */
34
+ export declare function datasetPackageDependencies(item: SiteBundleItem): SiteBundleItemReference[];
35
+ /**
36
+ * The dataset with each reference field moved to its target's new id. A
37
+ * target whose reference is dropped turns the field into plain text, which
38
+ * keeps every value it holds readable rather than pointing at nothing.
39
+ */
40
+ export declare function remapDatasetPackageIds(item: SiteBundleItem, idMap: ReadonlyMap<string, string | null>): SiteBundleItem;
41
+ /** The section's package hooks, as the declarations register them. */
42
+ export declare const datasetsSitePackage: PluginSiteBundleSectionPackage;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
18
+ /**
19
+ * A site's datasets as site package items (AGL-3533).
20
+ *
21
+ * A dataset names other datasets through its model's `reference` fields —
22
+ * the dataset a reference column points into. Those are the dependencies a
23
+ * package lists, and the ids a dataset kept beside an existing one (or mapped
24
+ * onto one the site already holds) has to move. Records keep their own ids
25
+ * under whichever dataset they land in, so the record ids a reference cell
26
+ * holds still resolve and nothing inside a record is rewritten.
27
+ *
28
+ * Pure and light: the declarations register these at boot, ahead of the
29
+ * section's Admin-SDK half, which loads on first use.
30
+ */ /** The package kind a dataset is listed as, as `plugins.config.json` declares it. */ export const DATASET_PACKAGE_KIND = 'dataset';
31
+ function modelFields(item) {
32
+ var _item_model;
33
+ const fields = item == null ? void 0 : (_item_model = item['model']) == null ? void 0 : _item_model.fields;
34
+ return fields && typeof fields === 'object' ? fields : {};
35
+ }
36
+ /** The datasets this one's reference fields point into. */ export function datasetPackageDependencies(item) {
37
+ const seen = new Set();
38
+ for (const field of Object.values(modelFields(item))){
39
+ var _field_reference;
40
+ const target = (field == null ? void 0 : field.type) === 'reference' ? (_field_reference = field.reference) == null ? void 0 : _field_reference.datasetId : undefined;
41
+ if (typeof target === 'string' && target && target !== item['$id']) seen.add(target);
42
+ }
43
+ return [
44
+ ...seen
45
+ ].map((id)=>({
46
+ kind: DATASET_PACKAGE_KIND,
47
+ id
48
+ }));
49
+ }
50
+ /**
51
+ * The dataset with each reference field moved to its target's new id. A
52
+ * target whose reference is dropped turns the field into plain text, which
53
+ * keeps every value it holds readable rather than pointing at nothing.
54
+ */ export function remapDatasetPackageIds(item, idMap) {
55
+ const fields = modelFields(item);
56
+ let changed = false;
57
+ const next = {};
58
+ for (const [fieldId, field] of Object.entries(fields)){
59
+ var _field_reference;
60
+ const target = (field == null ? void 0 : field.type) === 'reference' ? (_field_reference = field.reference) == null ? void 0 : _field_reference.datasetId : undefined;
61
+ const key = `${DATASET_PACKAGE_KIND}/${String(target)}`;
62
+ if (typeof target !== 'string' || !idMap.has(key)) {
63
+ next[fieldId] = field;
64
+ continue;
65
+ }
66
+ changed = true;
67
+ const moved = idMap.get(key);
68
+ if (moved) {
69
+ next[fieldId] = _extends({}, field, {
70
+ reference: _extends({}, field.reference, {
71
+ datasetId: moved
72
+ })
73
+ });
74
+ } else {
75
+ const { reference: _dropped } = field, rest = _object_without_properties_loose(field, [
76
+ "reference"
77
+ ]);
78
+ next[fieldId] = _extends({}, rest, {
79
+ type: 'text'
80
+ });
81
+ }
82
+ }
83
+ if (!changed) return item;
84
+ return _extends({}, item, {
85
+ model: _extends({}, item['model'], {
86
+ fields: next
87
+ })
88
+ });
89
+ }
90
+ /** The section's package hooks, as the declarations register them. */ export const datasetsSitePackage = {
91
+ dependencies: datasetPackageDependencies,
92
+ remapIds: remapDatasetPackageIds
93
+ };
94
+
95
+ //# sourceMappingURL=datasets-package.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/site-bundle/datasets-package.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginSiteBundleSectionPackage,\n SiteBundleItem,\n SiteBundleItemReference,\n} from '@aglyn/aglyn/plugin-manager/plugin-site-bundle'\n\n/**\n * A site's datasets as site package items (AGL-3533).\n *\n * A dataset names other datasets through its model's `reference` fields —\n * the dataset a reference column points into. Those are the dependencies a\n * package lists, and the ids a dataset kept beside an existing one (or mapped\n * onto one the site already holds) has to move. Records keep their own ids\n * under whichever dataset they land in, so the record ids a reference cell\n * holds still resolve and nothing inside a record is rewritten.\n *\n * Pure and light: the declarations register these at boot, ahead of the\n * section's Admin-SDK half, which loads on first use.\n */\n\n/** The package kind a dataset is listed as, as `plugins.config.json` declares it. */\nexport const DATASET_PACKAGE_KIND = 'dataset'\n\ntype ModelField = { type?: unknown; reference?: { datasetId?: unknown } & Record<string, unknown> }\n\nfunction modelFields(item: SiteBundleItem): Record<string, ModelField> {\n const fields = (item?.['model'] as { fields?: unknown } | undefined)?.fields\n return fields && typeof fields === 'object' ? (fields as Record<string, ModelField>) : {}\n}\n\n/** The datasets this one's reference fields point into. */\nexport function datasetPackageDependencies(item: SiteBundleItem): SiteBundleItemReference[] {\n const seen = new Set<string>()\n for (const field of Object.values(modelFields(item))) {\n const target = field?.type === 'reference' ? field.reference?.datasetId : undefined\n if (typeof target === 'string' && target && target !== item['$id']) seen.add(target)\n }\n return [...seen].map((id) => ({ kind: DATASET_PACKAGE_KIND, id }))\n}\n\n/**\n * The dataset with each reference field moved to its target's new id. A\n * target whose reference is dropped turns the field into plain text, which\n * keeps every value it holds readable rather than pointing at nothing.\n */\nexport function remapDatasetPackageIds(\n item: SiteBundleItem,\n idMap: ReadonlyMap<string, string | null>,\n): SiteBundleItem {\n const fields = modelFields(item)\n let changed = false\n const next: Record<string, ModelField> = {}\n for (const [fieldId, field] of Object.entries(fields)) {\n const target = field?.type === 'reference' ? field.reference?.datasetId : undefined\n const key = `${DATASET_PACKAGE_KIND}/${String(target)}`\n if (typeof target !== 'string' || !idMap.has(key)) {\n next[fieldId] = field\n continue\n }\n changed = true\n const moved = idMap.get(key)\n if (moved) {\n next[fieldId] = { ...field, reference: { ...field.reference, datasetId: moved } }\n } else {\n const { reference: _dropped, ...rest } = field\n next[fieldId] = { ...rest, type: 'text' }\n }\n }\n if (!changed) return item\n return { ...item, model: { ...(item['model'] as object), fields: next } }\n}\n\n/** The section's package hooks, as the declarations register them. */\nexport const datasetsSitePackage: PluginSiteBundleSectionPackage = {\n dependencies: datasetPackageDependencies,\n remapIds: remapDatasetPackageIds,\n}\n"],"names":["DATASET_PACKAGE_KIND","modelFields","item","fields","datasetPackageDependencies","seen","Set","field","Object","values","target","type","reference","datasetId","undefined","add","map","id","kind","remapDatasetPackageIds","idMap","changed","next","fieldId","entries","key","String","has","moved","get","_dropped","rest","model","datasetsSitePackage","dependencies","remapIds"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;;AAQD;;;;;;;;;;;;CAYC,GAED,mFAAmF,GACnF,OAAO,MAAMA,uBAAuB,UAAS;AAI7C,SAASC,YAAYC,IAAoB;QACvBA;IAAhB,MAAMC,SAAUD,yBAAAA,cAAAA,IAAM,CAAC,QAAQ,qBAAhB,AAACA,YAAsDC,MAAM;IAC5E,OAAOA,UAAU,OAAOA,WAAW,WAAYA,SAAwC,CAAC;AAC1F;AAEA,yDAAyD,GACzD,OAAO,SAASC,2BAA2BF,IAAoB;IAC7D,MAAMG,OAAO,IAAIC;IACjB,KAAK,MAAMC,SAASC,OAAOC,MAAM,CAACR,YAAYC,OAAQ;YACPK;QAA7C,MAAMG,SAASH,CAAAA,yBAAAA,MAAOI,IAAI,MAAK,eAAcJ,mBAAAA,MAAMK,SAAS,qBAAfL,iBAAiBM,SAAS,GAAGC;QAC1E,IAAI,OAAOJ,WAAW,YAAYA,UAAUA,WAAWR,IAAI,CAAC,MAAM,EAAEG,KAAKU,GAAG,CAACL;IAC/E;IACA,OAAO;WAAIL;KAAK,CAACW,GAAG,CAAC,CAACC,KAAQ,CAAA;YAAEC,MAAMlB;YAAsBiB;QAAG,CAAA;AACjE;AAEA;;;;CAIC,GACD,OAAO,SAASE,uBACdjB,IAAoB,EACpBkB,KAAyC;IAEzC,MAAMjB,SAASF,YAAYC;IAC3B,IAAImB,UAAU;IACd,MAAMC,OAAmC,CAAC;IAC1C,KAAK,MAAM,CAACC,SAAShB,MAAM,IAAIC,OAAOgB,OAAO,CAACrB,QAAS;YACRI;QAA7C,MAAMG,SAASH,CAAAA,yBAAAA,MAAOI,IAAI,MAAK,eAAcJ,mBAAAA,MAAMK,SAAS,qBAAfL,iBAAiBM,SAAS,GAAGC;QAC1E,MAAMW,MAAM,GAAGzB,qBAAqB,CAAC,EAAE0B,OAAOhB,SAAS;QACvD,IAAI,OAAOA,WAAW,YAAY,CAACU,MAAMO,GAAG,CAACF,MAAM;YACjDH,IAAI,CAACC,QAAQ,GAAGhB;YAChB;QACF;QACAc,UAAU;QACV,MAAMO,QAAQR,MAAMS,GAAG,CAACJ;QACxB,IAAIG,OAAO;YACTN,IAAI,CAACC,QAAQ,GAAG,aAAKhB;gBAAOK,WAAW,aAAKL,MAAMK,SAAS;oBAAEC,WAAWe;;;QAC1E,OAAO;YACL,MAAM,EAAEhB,WAAWkB,QAAQ,EAAW,GAAGvB,OAATwB,wCAASxB;;;YACzCe,IAAI,CAACC,QAAQ,GAAG,aAAKQ;gBAAMpB,MAAM;;QACnC;IACF;IACA,IAAI,CAACU,SAAS,OAAOnB;IACrB,OAAO,aAAKA;QAAM8B,OAAO,aAAM9B,IAAI,CAAC,QAAQ;YAAaC,QAAQmB;;;AACnE;AAEA,oEAAoE,GACpE,OAAO,MAAMW,sBAAsD;IACjEC,cAAc9B;IACd+B,UAAUhB;AACZ,EAAC"}
@@ -15,11 +15,11 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
15
15
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
16
  * See the License for the specific language governing permissions and
17
17
  * limitations under the License.
18
- */ import { datasetIntegrityFields, effectiveDatasetModel, validateDocument } from "@aglyn/aglyn/app-utils/dataset-models";
19
- import { checkDatasetQuota } from "@aglyn/aglyn/app-utils/plan-entitlements";
18
+ */ import { checkDatasetQuota } from "@aglyn/aglyn/app-utils/plan-entitlements";
20
19
  import { hostScopeToken, newResourceScopeFields } from "@aglyn/aglyn/app-utils/scope-tokens";
21
20
  import { firebaseAdmin, scopedToHost } from "@aglyn/tenant-data-admin";
22
21
  import { loadCustomFieldTypes } from "../server/custom-field-types.js";
22
+ import { datasetIntegrityFields, effectiveDatasetModel, validateDocument } from "../model/dataset-models.js";
23
23
  /**
24
24
  * A site's datasets in its whole-site backup (AGL-163), answered by the plugin
25
25
  * that keeps them (AGL-3080).