@aglyn/plugins-data 1.0.0-beta.218 → 1.0.0-beta.220
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +11 -9
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +6 -0
- package/src/lib/constants/bundle-common.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +47 -0
- package/src/lib/declarations.console-server.js +74 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/declarations.server.d.ts +43 -0
- package/src/lib/declarations.server.js +73 -0
- package/src/lib/declarations.server.js.map +1 -0
- package/src/lib/form-target/dataset-form-record-target.server.d.ts +40 -0
- package/src/lib/form-target/dataset-form-record-target.server.js +160 -0
- package/src/lib/form-target/dataset-form-record-target.server.js.map +1 -0
- package/src/lib/form-target/form-dataset-binding-token.d.ts +35 -0
- package/src/lib/form-target/form-dataset-binding-token.js +107 -0
- package/src/lib/form-target/form-dataset-binding-token.js.map +1 -0
- package/src/lib/form-target/form-dataset-binding.d.ts +50 -0
- package/src/lib/form-target/form-dataset-binding.js +74 -0
- package/src/lib/form-target/form-dataset-binding.js.map +1 -0
- package/src/lib/form-target/stamp-form-dataset-bindings.d.ts +39 -0
- package/src/lib/form-target/stamp-form-dataset-bindings.js +65 -0
- package/src/lib/form-target/stamp-form-dataset-bindings.js.map +1 -0
- package/src/lib/model/dataset-csv.d.ts +36 -0
- package/src/lib/model/dataset-csv.js +85 -0
- package/src/lib/model/dataset-csv.js.map +1 -0
- package/src/lib/model/dataset-io.d.ts +8 -7
- package/src/lib/model/dataset-io.js +9 -8
- package/src/lib/model/dataset-io.js.map +1 -1
- package/src/lib/model/dataset-record-list.d.ts +40 -0
- package/src/lib/model/dataset-record-list.js +61 -0
- package/src/lib/model/dataset-record-list.js.map +1 -0
- package/src/lib/model/dataset-record.d.ts +33 -0
- package/src/lib/model/dataset-record.js +68 -0
- package/src/lib/model/dataset-record.js.map +1 -0
- package/src/lib/plugin.js +3 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/repeat/dataset-repeat-rows.d.ts +2 -2
- package/src/lib/repeat/dataset-repeat-rows.js +2 -2
- package/src/lib/repeat/dataset-repeat-rows.js.map +1 -1
- package/src/lib/repeat/dataset-repeat-rows.server.d.ts +43 -0
- package/src/lib/repeat/dataset-repeat-rows.server.js +170 -0
- package/src/lib/repeat/dataset-repeat-rows.server.js.map +1 -0
- package/src/lib/repeat/dataset-repeat-source.js +3 -1
- package/src/lib/repeat/dataset-repeat-source.js.map +1 -1
- package/src/lib/server/announce-dataset-records.d.ts +37 -0
- package/src/lib/server/announce-dataset-records.js +54 -0
- package/src/lib/server/announce-dataset-records.js.map +1 -0
- package/src/lib/server/api-v1/datasets.d.ts +18 -0
- package/src/lib/server/api-v1/datasets.js +646 -0
- package/src/lib/server/api-v1/datasets.js.map +1 -0
- package/src/lib/server/api-v1/openapi.d.ts +26 -0
- package/src/lib/server/api-v1/openapi.js +260 -0
- package/src/lib/server/api-v1/openapi.js.map +1 -0
- package/src/lib/server/api-v1/usage.d.ts +26 -0
- package/src/lib/server/api-v1/usage.js +33 -0
- package/src/lib/server/api-v1/usage.js.map +1 -0
- package/src/lib/server/custom-field-types.d.ts +39 -0
- package/src/lib/server/custom-field-types.js +41 -0
- package/src/lib/server/custom-field-types.js.map +1 -0
- package/src/lib/server/dataset-figures.d.ts +61 -0
- package/src/lib/server/dataset-figures.js +413 -0
- package/src/lib/server/dataset-figures.js.map +1 -0
- package/src/lib/server/dataset-record-index.d.ts +18 -0
- package/src/lib/server/dataset-record-index.js +62 -0
- package/src/lib/server/dataset-record-index.js.map +1 -0
- package/src/lib/server/dataset-storage-meter.d.ts +29 -0
- package/src/lib/server/dataset-storage-meter.js +82 -0
- package/src/lib/server/dataset-storage-meter.js.map +1 -0
- package/src/lib/server/datasets-export-route.d.ts +57 -0
- package/src/lib/server/datasets-export-route.js +229 -0
- package/src/lib/server/datasets-export-route.js.map +1 -0
- package/src/lib/server/datasets-route.d.ts +38 -0
- package/src/lib/server/datasets-route.js +614 -0
- package/src/lib/server/datasets-route.js.map +1 -0
- package/src/lib/server.d.ts +23 -0
- package/src/lib/server.js +54 -0
- package/src/lib/server.js.map +1 -0
- package/src/lib/site-bundle/datasets-site-bundle.server.d.ts +87 -0
- package/src/lib/site-bundle/datasets-site-bundle.server.js +184 -0
- package/src/lib/site-bundle/datasets-site-bundle.server.js.map +1 -0
- package/src/lib/usage-axes.d.ts +24 -0
- package/src/lib/usage-axes.js +32 -0
- package/src/lib/usage-axes.js.map +1 -0
|
@@ -0,0 +1,29 @@
|
|
|
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 { PluginUsageMeterContext, PluginUsageMeterReading } from '@aglyn/aglyn/plugin-manager/plugin-usage-meters';
|
|
18
|
+
/**
|
|
19
|
+
* The data plugin's month in the usage sweep: what the workspace's datasets
|
|
20
|
+
* store, in the `dataStorageMb` the platform's data storage band and cost
|
|
21
|
+
* model read, and the plan-priced overage past that band (AGL-240).
|
|
22
|
+
*
|
|
23
|
+
* A STOCK, read at the end of the month it bills (AGL-2399): the closed-month
|
|
24
|
+
* sweep bills the last reading an in-progress sweep stamped inside the month,
|
|
25
|
+
* and measures now only when there is none; the in-progress sweep stamps its
|
|
26
|
+
* own reading as the period-end one, and a closed sweep never stamps, so
|
|
27
|
+
* every re-run of a closed month bills the same amount.
|
|
28
|
+
*/
|
|
29
|
+
export declare function measureDatasetStorage(context: PluginUsageMeterContext): Promise<PluginUsageMeterReading>;
|
|
@@ -0,0 +1,82 @@
|
|
|
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 { checkDataStorageQuota } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
17
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
18
|
+
/**
|
|
19
|
+
* Approximate aggregate dataset bytes for an org (AGL-240): per dataset, an
|
|
20
|
+
* aggregate record count × the average serialized size of a small sample —
|
|
21
|
+
* O(datasets) reads instead of O(records), good enough for metering.
|
|
22
|
+
*/ async function orgDatasetBytes(orgRef) {
|
|
23
|
+
const datasets = await orgRef.collection('datasets').get();
|
|
24
|
+
// Datasets in parallel (AGL-1141). Each is two independent reads and the
|
|
25
|
+
// results are summed, so walking them in sequence cost the sum of the
|
|
26
|
+
// latencies for no ordering benefit.
|
|
27
|
+
const perDataset = await Promise.all(datasets.docs.map(async (dataset)=>{
|
|
28
|
+
var _countSnapshot_data_count, _dataset_data;
|
|
29
|
+
const records = dataset.ref.collection('records');
|
|
30
|
+
const [countSnapshot, sample] = await Promise.all([
|
|
31
|
+
records.count().get(),
|
|
32
|
+
records.limit(50).get()
|
|
33
|
+
]);
|
|
34
|
+
const count = Number((_countSnapshot_data_count = countSnapshot.data().count) != null ? _countSnapshot_data_count : 0);
|
|
35
|
+
const sampleBytes = sample.docs.reduce((sum, record)=>{
|
|
36
|
+
var _record_data;
|
|
37
|
+
return sum + JSON.stringify((_record_data = record.data()) != null ? _record_data : {}).length;
|
|
38
|
+
}, 0);
|
|
39
|
+
const average = sample.size > 0 ? sampleBytes / sample.size : 0;
|
|
40
|
+
return JSON.stringify((_dataset_data = dataset.data()) != null ? _dataset_data : {}).length + Math.round(average * count);
|
|
41
|
+
}));
|
|
42
|
+
return perDataset.reduce((sum, bytes)=>sum + bytes, 0);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The data plugin's month in the usage sweep: what the workspace's datasets
|
|
46
|
+
* store, in the `dataStorageMb` the platform's data storage band and cost
|
|
47
|
+
* model read, and the plan-priced overage past that band (AGL-240).
|
|
48
|
+
*
|
|
49
|
+
* A STOCK, read at the end of the month it bills (AGL-2399): the closed-month
|
|
50
|
+
* sweep bills the last reading an in-progress sweep stamped inside the month,
|
|
51
|
+
* and measures now only when there is none; the in-progress sweep stamps its
|
|
52
|
+
* own reading as the period-end one, and a closed sweep never stamps, so
|
|
53
|
+
* every re-run of a closed month bills the same amount.
|
|
54
|
+
*/ export async function measureDatasetStorage(context) {
|
|
55
|
+
const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(context.orgId);
|
|
56
|
+
const bytes = await orgDatasetBytes(orgRef);
|
|
57
|
+
const dataStorageMbAtSweep = Math.round(bytes / (1024 * 1024) * 10) / 10;
|
|
58
|
+
// A reading an earlier sweep stamped, or `null` when there is none to
|
|
59
|
+
// trust — a malformed field falls back to measuring and never bills as 0.
|
|
60
|
+
const stamped = Number(context.previous['dataStorageMbAtPeriodEnd']);
|
|
61
|
+
const dataStorageMbAtPeriodEnd = Number.isFinite(stamped) && stamped >= 0 ? stamped : null;
|
|
62
|
+
const dataStorageMb = context.closed && dataStorageMbAtPeriodEnd !== null ? dataStorageMbAtPeriodEnd : dataStorageMbAtSweep;
|
|
63
|
+
// Plan-priced, not cost-plus: the overage the platform's data storage band
|
|
64
|
+
// sells past what the plan includes.
|
|
65
|
+
const quota = checkDataStorageQuota(context.org, dataStorageMb);
|
|
66
|
+
return {
|
|
67
|
+
fields: {
|
|
68
|
+
// The billed figure, under the name the console meter, the budget card
|
|
69
|
+
// and the cost model read.
|
|
70
|
+
dataStorageMb,
|
|
71
|
+
dataOverageUsd: quota.overageMonthlyUsd,
|
|
72
|
+
dataStorageMbAtSweep
|
|
73
|
+
},
|
|
74
|
+
periodEndFields: {
|
|
75
|
+
dataStorageMbAtPeriodEnd: dataStorageMbAtSweep
|
|
76
|
+
},
|
|
77
|
+
billedUsd: quota.overageMonthlyUsd,
|
|
78
|
+
periodEndBasis: context.closed && dataStorageMbAtPeriodEnd !== null
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
//# sourceMappingURL=dataset-storage-meter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/server/dataset-storage-meter.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 { checkDataStorageQuota } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport type {\n PluginUsageMeterContext,\n PluginUsageMeterReading,\n} from '@aglyn/aglyn/plugin-manager/plugin-usage-meters'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\n/**\n * Approximate aggregate dataset bytes for an org (AGL-240): per dataset, an\n * aggregate record count × the average serialized size of a small sample —\n * O(datasets) reads instead of O(records), good enough for metering.\n */\nasync function orgDatasetBytes(\n orgRef: FirebaseFirestore.DocumentReference,\n): Promise<number> {\n const datasets = await orgRef.collection('datasets').get()\n // Datasets in parallel (AGL-1141). Each is two independent reads and the\n // results are summed, so walking them in sequence cost the sum of the\n // latencies for no ordering benefit.\n const perDataset = await Promise.all(\n datasets.docs.map(async (dataset) => {\n const records = dataset.ref.collection('records')\n const [countSnapshot, sample] = await Promise.all([\n records.count().get(),\n records.limit(50).get(),\n ])\n const count = Number(countSnapshot.data().count ?? 0)\n const sampleBytes = sample.docs.reduce(\n (sum, record) => sum + JSON.stringify(record.data() ?? {}).length,\n 0,\n )\n const average = sample.size > 0 ? sampleBytes / sample.size : 0\n return (\n JSON.stringify(dataset.data() ?? {}).length + Math.round(average * count)\n )\n }),\n )\n return perDataset.reduce((sum, bytes) => sum + bytes, 0)\n}\n\n/**\n * The data plugin's month in the usage sweep: what the workspace's datasets\n * store, in the `dataStorageMb` the platform's data storage band and cost\n * model read, and the plan-priced overage past that band (AGL-240).\n *\n * A STOCK, read at the end of the month it bills (AGL-2399): the closed-month\n * sweep bills the last reading an in-progress sweep stamped inside the month,\n * and measures now only when there is none; the in-progress sweep stamps its\n * own reading as the period-end one, and a closed sweep never stamps, so\n * every re-run of a closed month bills the same amount.\n */\nexport async function measureDatasetStorage(\n context: PluginUsageMeterContext,\n): Promise<PluginUsageMeterReading> {\n const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(context.orgId)\n const bytes = await orgDatasetBytes(orgRef)\n const dataStorageMbAtSweep = Math.round((bytes / (1024 * 1024)) * 10) / 10\n // A reading an earlier sweep stamped, or `null` when there is none to\n // trust — a malformed field falls back to measuring and never bills as 0.\n const stamped = Number(context.previous['dataStorageMbAtPeriodEnd'])\n const dataStorageMbAtPeriodEnd =\n Number.isFinite(stamped) && stamped >= 0 ? stamped : null\n const dataStorageMb =\n context.closed && dataStorageMbAtPeriodEnd !== null\n ? dataStorageMbAtPeriodEnd\n : dataStorageMbAtSweep\n // Plan-priced, not cost-plus: the overage the platform's data storage band\n // sells past what the plan includes.\n const quota = checkDataStorageQuota(context.org as never, dataStorageMb)\n return {\n fields: {\n // The billed figure, under the name the console meter, the budget card\n // and the cost model read.\n dataStorageMb,\n dataOverageUsd: quota.overageMonthlyUsd,\n dataStorageMbAtSweep,\n },\n periodEndFields: { dataStorageMbAtPeriodEnd: dataStorageMbAtSweep },\n billedUsd: quota.overageMonthlyUsd,\n periodEndBasis: context.closed && dataStorageMbAtPeriodEnd !== null,\n }\n}\n"],"names":["checkDataStorageQuota","firebaseAdmin","orgDatasetBytes","orgRef","datasets","collection","get","perDataset","Promise","all","docs","map","dataset","countSnapshot","records","ref","sample","count","limit","Number","data","sampleBytes","reduce","sum","record","JSON","stringify","length","average","size","Math","round","bytes","measureDatasetStorage","context","app","firestore","doc","orgId","dataStorageMbAtSweep","stamped","previous","dataStorageMbAtPeriodEnd","isFinite","dataStorageMb","closed","quota","org","fields","dataOverageUsd","overageMonthlyUsd","periodEndFields","billedUsd","periodEndBasis"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,qBAAqB,QAAQ,2CAA0C;AAKhF,SAASC,aAAa,QAAQ,2BAA0B;AAExD;;;;CAIC,GACD,eAAeC,gBACbC,MAA2C;IAE3C,MAAMC,WAAW,MAAMD,OAAOE,UAAU,CAAC,YAAYC,GAAG;IACxD,yEAAyE;IACzE,sEAAsE;IACtE,qCAAqC;IACrC,MAAMC,aAAa,MAAMC,QAAQC,GAAG,CAClCL,SAASM,IAAI,CAACC,GAAG,CAAC,OAAOC;YAMFC,2BAOJD;QAZjB,MAAME,UAAUF,QAAQG,GAAG,CAACV,UAAU,CAAC;QACvC,MAAM,CAACQ,eAAeG,OAAO,GAAG,MAAMR,QAAQC,GAAG,CAAC;YAChDK,QAAQG,KAAK,GAAGX,GAAG;YACnBQ,QAAQI,KAAK,CAAC,IAAIZ,GAAG;SACtB;QACD,MAAMW,QAAQE,QAAON,4BAAAA,cAAcO,IAAI,GAAGH,KAAK,YAA1BJ,4BAA8B;QACnD,MAAMQ,cAAcL,OAAON,IAAI,CAACY,MAAM,CACpC,CAACC,KAAKC;gBAAgCA;mBAArBD,MAAME,KAAKC,SAAS,EAACF,eAAAA,OAAOJ,IAAI,cAAXI,eAAiB,CAAC,GAAGG,MAAM;WACjE;QAEF,MAAMC,UAAUZ,OAAOa,IAAI,GAAG,IAAIR,cAAcL,OAAOa,IAAI,GAAG;QAC9D,OACEJ,KAAKC,SAAS,EAACd,gBAAAA,QAAQQ,IAAI,cAAZR,gBAAkB,CAAC,GAAGe,MAAM,GAAGG,KAAKC,KAAK,CAACH,UAAUX;IAEvE;IAEF,OAAOV,WAAWe,MAAM,CAAC,CAACC,KAAKS,QAAUT,MAAMS,OAAO;AACxD;AAEA;;;;;;;;;;CAUC,GACD,OAAO,eAAeC,sBACpBC,OAAgC;IAEhC,MAAM/B,SAASF,cAAckC,GAAG,GAAGC,SAAS,GAAG/B,UAAU,CAAC,QAAQgC,GAAG,CAACH,QAAQI,KAAK;IACnF,MAAMN,QAAQ,MAAM9B,gBAAgBC;IACpC,MAAMoC,uBAAuBT,KAAKC,KAAK,CAAC,AAACC,QAAS,CAAA,OAAO,IAAG,IAAM,MAAM;IACxE,sEAAsE;IACtE,0EAA0E;IAC1E,MAAMQ,UAAUrB,OAAOe,QAAQO,QAAQ,CAAC,2BAA2B;IACnE,MAAMC,2BACJvB,OAAOwB,QAAQ,CAACH,YAAYA,WAAW,IAAIA,UAAU;IACvD,MAAMI,gBACJV,QAAQW,MAAM,IAAIH,6BAA6B,OAC3CA,2BACAH;IACN,2EAA2E;IAC3E,qCAAqC;IACrC,MAAMO,QAAQ9C,sBAAsBkC,QAAQa,GAAG,EAAWH;IAC1D,OAAO;QACLI,QAAQ;YACN,uEAAuE;YACvE,2BAA2B;YAC3BJ;YACAK,gBAAgBH,MAAMI,iBAAiB;YACvCX;QACF;QACAY,iBAAiB;YAAET,0BAA0BH;QAAqB;QAClEa,WAAWN,MAAMI,iBAAiB;QAClCG,gBAAgBnB,QAAQW,MAAM,IAAIH,6BAA6B;IACjE;AACF"}
|
|
@@ -0,0 +1,57 @@
|
|
|
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 PluginWebApiHandler } from '@aglyn/aglyn/server';
|
|
18
|
+
/** Header naming the row count the server undertook to send. */
|
|
19
|
+
export declare const EXPORT_ROWS_HEADER = "X-Aglyn-Export-Rows";
|
|
20
|
+
/**
|
|
21
|
+
* Complete dataset export, streamed (AGL-2335).
|
|
22
|
+
*
|
|
23
|
+
* `run-an-agency-workspace.md` sells this as half of a **full handover** —
|
|
24
|
+
* the artifact a departing client is told is theirs. What it actually
|
|
25
|
+
* produced was `records`, the card's live listener window: `limit(500)`
|
|
26
|
+
* **with no `orderBy`**. Firestore answers an unordered limit in document-id
|
|
27
|
+
* order over auto-ids, so a 2,000-record dataset did not export "the first
|
|
28
|
+
* 500", it exported *500 unpredictable rows*, and re-exporting could produce
|
|
29
|
+
* a different set. `sortDatasetRecords` then sorted that sample, which made
|
|
30
|
+
* the result look ordered and be arbitrary. The button said `CSV`.
|
|
31
|
+
*
|
|
32
|
+
* Three things this route does that the client could not:
|
|
33
|
+
*
|
|
34
|
+
* 1. **Every row.** Cursor-paged over the whole collection, streamed out as
|
|
35
|
+
* it goes, so completeness does not cost a browser hang or a giant buffer
|
|
36
|
+
* — the reason the obvious client-side fix was unsafe on a tier where
|
|
37
|
+
* `recordsPerDataset` is `UNLIMITED`.
|
|
38
|
+
* 2. **A deterministic total order.** `orderBy(FieldPath.documentId())`.
|
|
39
|
+
* Deliberately NOT `orderBy('order')`, the field the table sorts by:
|
|
40
|
+
* Firestore silently DROPS documents missing an ordered field, and legacy
|
|
41
|
+
* records have no `order`, so ordering by it would reintroduce exactly the
|
|
42
|
+
* silent row loss this issue is about — with an index requirement on top.
|
|
43
|
+
* Document id is present on every document, is a total order, and is the
|
|
44
|
+
* order the old window happened to arrive in. Same order, now complete.
|
|
45
|
+
* 3. **A checkable promise.** `X-Aglyn-Export-Rows` carries a `count()`
|
|
46
|
+
* aggregate taken before the first page, so the client can prove the file
|
|
47
|
+
* it received is whole. A stream that dies halfway yields a perfectly
|
|
48
|
+
* well-formed shorter file; nothing about the bytes says they are short.
|
|
49
|
+
*
|
|
50
|
+
* **No entitlement gate, deliberately.** The client button never had one and
|
|
51
|
+
* this is the customer's own data — the same reasoning `/api/orgs/export-data`
|
|
52
|
+
* records for the workspace export. Adding a plan gate here would take a
|
|
53
|
+
* capability away from people who have it today, on the export path of all
|
|
54
|
+
* places. The `release_data_store` flag gate is kept, because that is
|
|
55
|
+
* whether the feature exists at all rather than whether it is paid for.
|
|
56
|
+
*/
|
|
57
|
+
export declare const datasetsExportHandler: PluginWebApiHandler;
|
|
@@ -0,0 +1,229 @@
|
|
|
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 { effectiveDatasetModel, memberCanSee, pluginRequestFromWeb } from "@aglyn/aglyn/server";
|
|
17
|
+
import { emailUnverifiedResponse, firebaseAdmin, isImpersonationSession, isServerReleaseFlagOnForOrg, lockdownRefusal, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
18
|
+
import { FieldPath, FieldValue } from "firebase-admin/firestore";
|
|
19
|
+
import { addAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write";
|
|
20
|
+
import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
21
|
+
import { datasetCsvHeader, datasetCsvRow, datasetRecordToJson } from "../model/dataset-csv.js";
|
|
22
|
+
/**
|
|
23
|
+
* Records read per round trip. Not a cap — the stream keeps paging until
|
|
24
|
+
* the collection is exhausted. It bounds what is held at once, which is the
|
|
25
|
+
* only thing a page size should ever bound.
|
|
26
|
+
*/ const PAGE_SIZE = 500;
|
|
27
|
+
/** Header naming the row count the server undertook to send. */ export const EXPORT_ROWS_HEADER = 'X-Aglyn-Export-Rows';
|
|
28
|
+
const json = (body, status)=>Response.json(body, {
|
|
29
|
+
status
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Complete dataset export, streamed (AGL-2335).
|
|
33
|
+
*
|
|
34
|
+
* `run-an-agency-workspace.md` sells this as half of a **full handover** —
|
|
35
|
+
* the artifact a departing client is told is theirs. What it actually
|
|
36
|
+
* produced was `records`, the card's live listener window: `limit(500)`
|
|
37
|
+
* **with no `orderBy`**. Firestore answers an unordered limit in document-id
|
|
38
|
+
* order over auto-ids, so a 2,000-record dataset did not export "the first
|
|
39
|
+
* 500", it exported *500 unpredictable rows*, and re-exporting could produce
|
|
40
|
+
* a different set. `sortDatasetRecords` then sorted that sample, which made
|
|
41
|
+
* the result look ordered and be arbitrary. The button said `CSV`.
|
|
42
|
+
*
|
|
43
|
+
* Three things this route does that the client could not:
|
|
44
|
+
*
|
|
45
|
+
* 1. **Every row.** Cursor-paged over the whole collection, streamed out as
|
|
46
|
+
* it goes, so completeness does not cost a browser hang or a giant buffer
|
|
47
|
+
* — the reason the obvious client-side fix was unsafe on a tier where
|
|
48
|
+
* `recordsPerDataset` is `UNLIMITED`.
|
|
49
|
+
* 2. **A deterministic total order.** `orderBy(FieldPath.documentId())`.
|
|
50
|
+
* Deliberately NOT `orderBy('order')`, the field the table sorts by:
|
|
51
|
+
* Firestore silently DROPS documents missing an ordered field, and legacy
|
|
52
|
+
* records have no `order`, so ordering by it would reintroduce exactly the
|
|
53
|
+
* silent row loss this issue is about — with an index requirement on top.
|
|
54
|
+
* Document id is present on every document, is a total order, and is the
|
|
55
|
+
* order the old window happened to arrive in. Same order, now complete.
|
|
56
|
+
* 3. **A checkable promise.** `X-Aglyn-Export-Rows` carries a `count()`
|
|
57
|
+
* aggregate taken before the first page, so the client can prove the file
|
|
58
|
+
* it received is whole. A stream that dies halfway yields a perfectly
|
|
59
|
+
* well-formed shorter file; nothing about the bytes says they are short.
|
|
60
|
+
*
|
|
61
|
+
* **No entitlement gate, deliberately.** The client button never had one and
|
|
62
|
+
* this is the customer's own data — the same reasoning `/api/orgs/export-data`
|
|
63
|
+
* records for the workspace export. Adding a plan gate here would take a
|
|
64
|
+
* capability away from people who have it today, on the export path of all
|
|
65
|
+
* places. The `release_data_store` flag gate is kept, because that is
|
|
66
|
+
* whether the feature exists at all rather than whether it is paid for.
|
|
67
|
+
*/ export const datasetsExportHandler = async (request)=>{
|
|
68
|
+
var _headers_authorization, _ref, _ref1;
|
|
69
|
+
const { method, query, headers: rawHeaders } = await pluginRequestFromWeb(request);
|
|
70
|
+
const headers = rawHeaders;
|
|
71
|
+
if (method !== 'GET') return json({
|
|
72
|
+
error: 'Method not allowed'
|
|
73
|
+
}, 405);
|
|
74
|
+
const authorization = (_headers_authorization = headers.authorization) != null ? _headers_authorization : '';
|
|
75
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
|
|
76
|
+
if (!idToken) return json({
|
|
77
|
+
error: 'Unauthenticated'
|
|
78
|
+
}, 401);
|
|
79
|
+
const orgId = String((_ref = query == null ? void 0 : query['orgId']) != null ? _ref : '');
|
|
80
|
+
const datasetId = String((_ref1 = query == null ? void 0 : query['datasetId']) != null ? _ref1 : '');
|
|
81
|
+
const format = (query == null ? void 0 : query['format']) === 'json' ? 'json' : 'csv';
|
|
82
|
+
if (!orgId || !datasetId) {
|
|
83
|
+
return json({
|
|
84
|
+
error: 'Missing orgId or datasetId'
|
|
85
|
+
}, 400);
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
var _data_count, _dataset_displayName;
|
|
89
|
+
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
90
|
+
if (!decoded.email_verified && !isImpersonationSession(decoded)) {
|
|
91
|
+
return emailUnverifiedResponse();
|
|
92
|
+
}
|
|
93
|
+
const staff = decoded['staff'] === true;
|
|
94
|
+
const membership = await resolveOrgMembership(decoded.uid, orgId);
|
|
95
|
+
const member = membership == null ? void 0 : membership.member;
|
|
96
|
+
// Reading is not writing: every member who can SEE the dataset may take
|
|
97
|
+
// a copy of it. The writer-role gate on `/api/orgs/datasets` is about
|
|
98
|
+
// quota-consuming creates and has no business here.
|
|
99
|
+
if (!member && !staff) return json({
|
|
100
|
+
error: 'Not found'
|
|
101
|
+
}, 404);
|
|
102
|
+
if (!staff && !await isServerReleaseFlagOnForOrg('release_data_store', orgId)) {
|
|
103
|
+
return json({
|
|
104
|
+
error: 'Not available'
|
|
105
|
+
}, 404);
|
|
106
|
+
}
|
|
107
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
108
|
+
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
109
|
+
const orgSnapshot = await orgRef.get();
|
|
110
|
+
if (!orgSnapshot.exists) return json({
|
|
111
|
+
error: 'Unknown organization'
|
|
112
|
+
}, 404);
|
|
113
|
+
const org = orgSnapshot.data();
|
|
114
|
+
const locked = await lockdownRefusal({
|
|
115
|
+
request,
|
|
116
|
+
staff,
|
|
117
|
+
uid: decoded.uid,
|
|
118
|
+
org
|
|
119
|
+
});
|
|
120
|
+
if (locked) return locked;
|
|
121
|
+
const datasetRef = orgRef.collection('datasets').doc(datasetId);
|
|
122
|
+
const datasetSnapshot = await datasetRef.get();
|
|
123
|
+
if (!datasetSnapshot.exists) {
|
|
124
|
+
return json({
|
|
125
|
+
error: 'Unknown dataset'
|
|
126
|
+
}, 404);
|
|
127
|
+
}
|
|
128
|
+
const dataset = datasetSnapshot.data();
|
|
129
|
+
// Scoped sharing (AGL-1037/1044). The records subcollection carries no
|
|
130
|
+
// `visibleTo` of its own — scope lives on the parent dataset — so this
|
|
131
|
+
// is the ONLY place the check can be made, and skipping it would let a
|
|
132
|
+
// single-site collaborator export an internal dataset wholesale. The
|
|
133
|
+
// Admin SDK bypasses rules entirely, so `memberCanSee` IS the
|
|
134
|
+
// enforcement here rather than a second opinion about it; membership
|
|
135
|
+
// was already refused above, which is the precondition it documents.
|
|
136
|
+
if (!staff && !memberCanSee(member, dataset['visibleTo'])) {
|
|
137
|
+
return json({
|
|
138
|
+
error: 'Not found'
|
|
139
|
+
}, 404);
|
|
140
|
+
}
|
|
141
|
+
const model = effectiveDatasetModel(dataset);
|
|
142
|
+
const recordsRef = datasetRef.collection('records');
|
|
143
|
+
// Taken BEFORE the first page, so it describes the collection the export
|
|
144
|
+
// started from. A `count()` aggregate bills one read per 1,000 documents
|
|
145
|
+
// — cheap next to the export it is describing, and the only way the
|
|
146
|
+
// client can tell a complete file from a truncated one.
|
|
147
|
+
const total = Number((_data_count = (await recordsRef.count().get()).data().count) != null ? _data_count : 0);
|
|
148
|
+
const encoder = new TextEncoder();
|
|
149
|
+
const encode = (text)=>encoder.encode(text);
|
|
150
|
+
let cursor = null;
|
|
151
|
+
let phase = 'head';
|
|
152
|
+
let emitted = 0;
|
|
153
|
+
const stream = new ReadableStream({
|
|
154
|
+
async pull (controller) {
|
|
155
|
+
try {
|
|
156
|
+
if (phase === 'head') {
|
|
157
|
+
phase = 'body';
|
|
158
|
+
controller.enqueue(encode(format === 'csv' ? datasetCsvHeader(model) : '['));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (phase === 'body') {
|
|
162
|
+
let page = recordsRef.orderBy(FieldPath.documentId()).limit(PAGE_SIZE);
|
|
163
|
+
if (cursor) page = page.startAfter(cursor);
|
|
164
|
+
const snapshot = await page.get();
|
|
165
|
+
if (snapshot.empty) {
|
|
166
|
+
phase = 'tail';
|
|
167
|
+
} else {
|
|
168
|
+
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
169
|
+
if (snapshot.docs.length < PAGE_SIZE) phase = 'tail';
|
|
170
|
+
const first = emitted === 0;
|
|
171
|
+
const lines = snapshot.docs.map((entry)=>{
|
|
172
|
+
var _values, _entry_data;
|
|
173
|
+
const values = (_values = ((_entry_data = entry.data()) != null ? _entry_data : {})['values']) != null ? _values : {};
|
|
174
|
+
emitted += 1;
|
|
175
|
+
return format === 'csv' ? datasetCsvRow(model, values) : JSON.stringify(datasetRecordToJson(model, values));
|
|
176
|
+
});
|
|
177
|
+
controller.enqueue(encode(format === 'csv' ? `\n${lines.join('\n')}` : `${first ? '' : ','}${lines.join(',')}`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// tail
|
|
182
|
+
controller.enqueue(encode(format === 'csv' ? '\n' : ']'));
|
|
183
|
+
controller.close();
|
|
184
|
+
} catch (error) {
|
|
185
|
+
// Erroring the stream truncates the download rather than
|
|
186
|
+
// completing it short — a half-written file that the browser
|
|
187
|
+
// reports as a failed transfer, not a quiet partial handover.
|
|
188
|
+
controller.error(error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
// Ids and counts only, never content (the AGL-1443 rule the workspace
|
|
193
|
+
// export follows). A full copy of a dataset leaving the platform is
|
|
194
|
+
// worth a row; what was in it is not ours to log.
|
|
195
|
+
void addAdminAudit(firestore, {
|
|
196
|
+
actorUid: decoded.uid,
|
|
197
|
+
action: 'dataset.exported',
|
|
198
|
+
target: `orgs/${orgId}/datasets/${datasetId}`,
|
|
199
|
+
before: null,
|
|
200
|
+
after: {
|
|
201
|
+
format,
|
|
202
|
+
records: total
|
|
203
|
+
},
|
|
204
|
+
at: FieldValue.serverTimestamp()
|
|
205
|
+
}).catch(()=>undefined);
|
|
206
|
+
const base = String((_dataset_displayName = dataset['displayName']) != null ? _dataset_displayName : 'collection').replace(/[^A-Za-z0-9_-]+/g, '-').toLowerCase() || 'collection';
|
|
207
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
208
|
+
return new Response(stream, {
|
|
209
|
+
status: 200,
|
|
210
|
+
headers: {
|
|
211
|
+
'Content-Type': format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json; charset=utf-8',
|
|
212
|
+
'Content-Disposition': `attachment; filename="${base}-${stamp}.${format}"`,
|
|
213
|
+
[EXPORT_ROWS_HEADER]: String(total),
|
|
214
|
+
'Cache-Control': 'no-store, private'
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
// A refused credential is a 401, not a fault of ours (AGL-1993); anything
|
|
219
|
+
// else keeps the answer below.
|
|
220
|
+
if (isRefusedIdToken(error)) return json({
|
|
221
|
+
error: 'Unauthenticated'
|
|
222
|
+
}, 401);
|
|
223
|
+
return json({
|
|
224
|
+
error: 'Export failed'
|
|
225
|
+
}, 500);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
//# sourceMappingURL=datasets-export-route.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/server/datasets-export-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 effectiveDatasetModel,\n memberCanSee,\n pluginRequestFromWeb,\n type PluginWebApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n isServerReleaseFlagOnForOrg,\n lockdownRefusal,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n datasetCsvHeader,\n datasetCsvRow,\n datasetRecordToJson,\n} from '../model/dataset-csv'\n\n/**\n * Records read per round trip. Not a cap — the stream keeps paging until\n * the collection is exhausted. It bounds what is held at once, which is the\n * only thing a page size should ever bound.\n */\nconst PAGE_SIZE = 500\n\n/** Header naming the row count the server undertook to send. */\nexport const EXPORT_ROWS_HEADER = 'X-Aglyn-Export-Rows'\n\nconst json = (body: unknown, status: number) => Response.json(body, { status })\n\n/**\n * Complete dataset export, streamed (AGL-2335).\n *\n * `run-an-agency-workspace.md` sells this as half of a **full handover** —\n * the artifact a departing client is told is theirs. What it actually\n * produced was `records`, the card's live listener window: `limit(500)`\n * **with no `orderBy`**. Firestore answers an unordered limit in document-id\n * order over auto-ids, so a 2,000-record dataset did not export \"the first\n * 500\", it exported *500 unpredictable rows*, and re-exporting could produce\n * a different set. `sortDatasetRecords` then sorted that sample, which made\n * the result look ordered and be arbitrary. The button said `CSV`.\n *\n * Three things this route does that the client could not:\n *\n * 1. **Every row.** Cursor-paged over the whole collection, streamed out as\n * it goes, so completeness does not cost a browser hang or a giant buffer\n * — the reason the obvious client-side fix was unsafe on a tier where\n * `recordsPerDataset` is `UNLIMITED`.\n * 2. **A deterministic total order.** `orderBy(FieldPath.documentId())`.\n * Deliberately NOT `orderBy('order')`, the field the table sorts by:\n * Firestore silently DROPS documents missing an ordered field, and legacy\n * records have no `order`, so ordering by it would reintroduce exactly the\n * silent row loss this issue is about — with an index requirement on top.\n * Document id is present on every document, is a total order, and is the\n * order the old window happened to arrive in. Same order, now complete.\n * 3. **A checkable promise.** `X-Aglyn-Export-Rows` carries a `count()`\n * aggregate taken before the first page, so the client can prove the file\n * it received is whole. A stream that dies halfway yields a perfectly\n * well-formed shorter file; nothing about the bytes says they are short.\n *\n * **No entitlement gate, deliberately.** The client button never had one and\n * this is the customer's own data — the same reasoning `/api/orgs/export-data`\n * records for the workspace export. Adding a plan gate here would take a\n * capability away from people who have it today, on the export path of all\n * places. The `release_data_store` flag gate is kept, because that is\n * whether the feature exists at all rather than whether it is paid for.\n */\nexport const datasetsExportHandler: PluginWebApiHandler = async (request) => {\n const { method, query, headers: rawHeaders } =\n await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') return json({ error: 'Method not allowed' }, 405)\n\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return json({ error: 'Unauthenticated' }, 401)\n\n const orgId = String(query?.['orgId'] ?? '')\n const datasetId = String(query?.['datasetId'] ?? '')\n const format = query?.['format'] === 'json' ? 'json' : 'csv'\n if (!orgId || !datasetId) {\n return json({ error: 'Missing orgId or datasetId' }, 400)\n }\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 staff = decoded['staff'] === true\n const membership = await resolveOrgMembership(decoded.uid, orgId)\n const member = membership?.member\n // Reading is not writing: every member who can SEE the dataset may take\n // a copy of it. The writer-role gate on `/api/orgs/datasets` is about\n // quota-consuming creates and has no business here.\n if (!member && !staff) return json({ error: 'Not found' }, 404)\n\n if (\n !staff &&\n !(await isServerReleaseFlagOnForOrg('release_data_store', orgId))\n ) {\n return json({ error: 'Not available' }, 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) return json({ error: 'Unknown organization' }, 404)\n const org = orgSnapshot.data()\n\n const locked = await lockdownRefusal({\n request,\n staff,\n uid: decoded.uid,\n org,\n })\n if (locked) return locked\n\n const datasetRef = orgRef.collection('datasets').doc(datasetId)\n const datasetSnapshot = await datasetRef.get()\n if (!datasetSnapshot.exists) {\n return json({ error: 'Unknown dataset' }, 404)\n }\n const dataset = datasetSnapshot.data() as Record<string, unknown>\n\n // Scoped sharing (AGL-1037/1044). The records subcollection carries no\n // `visibleTo` of its own — scope lives on the parent dataset — so this\n // is the ONLY place the check can be made, and skipping it would let a\n // single-site collaborator export an internal dataset wholesale. The\n // Admin SDK bypasses rules entirely, so `memberCanSee` IS the\n // enforcement here rather than a second opinion about it; membership\n // was already refused above, which is the precondition it documents.\n if (\n !staff &&\n !memberCanSee(member, dataset['visibleTo'] as string[] | undefined)\n ) {\n return json({ error: 'Not found' }, 404)\n }\n\n const model = effectiveDatasetModel(dataset as never)\n const recordsRef = datasetRef.collection('records')\n // Taken BEFORE the first page, so it describes the collection the export\n // started from. A `count()` aggregate bills one read per 1,000 documents\n // — cheap next to the export it is describing, and the only way the\n // client can tell a complete file from a truncated one.\n const total = Number(\n (await recordsRef.count().get()).data().count ?? 0,\n )\n\n const encoder = new TextEncoder()\n const encode = (text: string) => encoder.encode(text)\n let cursor: FirebaseFirestore.QueryDocumentSnapshot | null = null\n let phase: 'head' | 'body' | 'tail' = 'head'\n let emitted = 0\n\n const stream = new ReadableStream<Uint8Array>({\n async pull(controller) {\n try {\n if (phase === 'head') {\n phase = 'body'\n controller.enqueue(\n encode(format === 'csv' ? datasetCsvHeader(model) : '['),\n )\n return\n }\n if (phase === 'body') {\n let page = recordsRef\n .orderBy(FieldPath.documentId())\n .limit(PAGE_SIZE)\n if (cursor) page = page.startAfter(cursor)\n const snapshot = await page.get()\n if (snapshot.empty) {\n phase = 'tail'\n } else {\n cursor = snapshot.docs[snapshot.docs.length - 1]\n if (snapshot.docs.length < PAGE_SIZE) phase = 'tail'\n const first = emitted === 0\n const lines = snapshot.docs.map((entry) => {\n const values = ((entry.data() ?? {})['values'] ??\n {}) as Record<string, unknown>\n emitted += 1\n return format === 'csv'\n ? datasetCsvRow(model, values)\n : JSON.stringify(datasetRecordToJson(model, values))\n })\n controller.enqueue(\n encode(\n format === 'csv'\n ? `\\n${lines.join('\\n')}`\n : `${first ? '' : ','}${lines.join(',')}`,\n ),\n )\n return\n }\n }\n // tail\n controller.enqueue(encode(format === 'csv' ? '\\n' : ']'))\n controller.close()\n } catch (error) {\n // Erroring the stream truncates the download rather than\n // completing it short — a half-written file that the browser\n // reports as a failed transfer, not a quiet partial handover.\n controller.error(error)\n }\n },\n })\n\n // Ids and counts only, never content (the AGL-1443 rule the workspace\n // export follows). A full copy of a dataset leaving the platform is\n // worth a row; what was in it is not ours to log.\n void addAdminAudit(firestore, {\n actorUid: decoded.uid,\n action: 'dataset.exported',\n target: `orgs/${orgId}/datasets/${datasetId}`,\n before: null,\n after: { format, records: total },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n const base =\n String(dataset['displayName'] ?? 'collection')\n .replace(/[^A-Za-z0-9_-]+/g, '-')\n .toLowerCase() || 'collection'\n const stamp = new Date().toISOString().slice(0, 10)\n return new Response(stream, {\n status: 200,\n headers: {\n 'Content-Type':\n format === 'csv'\n ? 'text/csv; charset=utf-8'\n : 'application/json; charset=utf-8',\n 'Content-Disposition': `attachment; filename=\"${base}-${stamp}.${format}\"`,\n [EXPORT_ROWS_HEADER]: String(total),\n 'Cache-Control': 'no-store, private',\n },\n })\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)) return json({ error: 'Unauthenticated' }, 401)\n return json({ error: 'Export failed' }, 500)\n }\n}\n"],"names":["effectiveDatasetModel","memberCanSee","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","isServerReleaseFlagOnForOrg","lockdownRefusal","resolveOrgMembership","FieldPath","FieldValue","addAdminAudit","isRefusedIdToken","datasetCsvHeader","datasetCsvRow","datasetRecordToJson","PAGE_SIZE","EXPORT_ROWS_HEADER","json","body","status","Response","datasetsExportHandler","request","headers","method","query","rawHeaders","error","authorization","idToken","startsWith","slice","length","undefined","orgId","String","datasetId","format","dataset","decoded","app","auth","verifyIdToken","email_verified","staff","membership","uid","member","firestore","orgRef","collection","doc","orgSnapshot","get","exists","org","data","locked","datasetRef","datasetSnapshot","model","recordsRef","total","Number","count","encoder","TextEncoder","encode","text","cursor","phase","emitted","stream","ReadableStream","pull","controller","enqueue","page","orderBy","documentId","limit","startAfter","snapshot","empty","docs","first","lines","map","entry","values","JSON","stringify","join","close","actorUid","action","target","before","after","records","at","serverTimestamp","catch","base","replace","toLowerCase","stamp","Date","toISOString"],"mappings":"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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 PluginWebApiHandler } from '@aglyn/aglyn/server';
|
|
18
|
+
/**
|
|
19
|
+
* Dataset/record creation API (AGL-473): creates moved out of the client
|
|
20
|
+
* SDK so quotas and entitlements are enforced server-side — Firestore
|
|
21
|
+
* rules deny client-side `create` on `orgs/{orgId}/datasets/**` (updates
|
|
22
|
+
* and deletes stay client-direct, they don't consume quota). Actions:
|
|
23
|
+
*
|
|
24
|
+
* - `create-dataset`: `dataStore` entitlement + `checkDatasetQuota`
|
|
25
|
+
* (addon-aware, org-scoped). An optional `hostId` names the site the
|
|
26
|
+
* dataset is created from, which the org's Default sharing can narrow it
|
|
27
|
+
* to.
|
|
28
|
+
* - `create-record`: `recordsPerDataset` quota; values are re-coerced
|
|
29
|
+
* and re-validated against the dataset's model server-side.
|
|
30
|
+
* - `import-records`: batch create with the whole batch fitting the cap.
|
|
31
|
+
*
|
|
32
|
+
* Since AGL-2163 both record actions ALSO enforce `checkDataStorageQuota`.
|
|
33
|
+
* See {@link refuseIfDataStorageBlocked}: that check's `allowed` field had no
|
|
34
|
+
* reader anywhere in the platform while its docblock said free "hard-blocks
|
|
35
|
+
* at the included size", so the byte band was documentation. `recordsPerDataset`
|
|
36
|
+
* counts ROWS and does not bound their size, so it was never the same limit.
|
|
37
|
+
*/
|
|
38
|
+
export declare const datasetsHandler: PluginWebApiHandler;
|