@aglyn/plugins-data 1.0.0-beta.221 → 1.0.0-beta.223
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 +9 -6
- package/package.json +10 -10
- package/src/index.d.ts +1 -1
- package/src/index.js +1 -1
- package/src/index.js.map +1 -1
- package/src/lib/artifact/dataset-schema-artifact.server.d.ts +57 -0
- package/src/lib/artifact/dataset-schema-artifact.server.js +308 -0
- package/src/lib/artifact/dataset-schema-artifact.server.js.map +1 -0
- package/src/lib/artifact/dataset-schema.d.ts +136 -0
- package/src/lib/artifact/dataset-schema.js +232 -0
- package/src/lib/artifact/dataset-schema.js.map +1 -0
- package/src/lib/components/dataset-record-dialog.component.d.ts +1 -1
- package/src/lib/components/dataset-record-dialog.component.js +1 -1
- package/src/lib/components/dataset-record-dialog.component.js.map +1 -1
- package/src/lib/components/dataset-record-filter.d.ts +1 -1
- package/src/lib/components/dataset-record-filter.js +2 -2
- package/src/lib/components/dataset-record-filter.js.map +1 -1
- package/src/lib/components/dataset-schema-dialog.component.d.ts +1 -1
- package/src/lib/components/dataset-schema-dialog.component.js +55 -23
- package/src/lib/components/dataset-schema-dialog.component.js.map +1 -1
- package/src/lib/components/host-datasets-card.component.js +91 -403
- package/src/lib/components/host-datasets-card.component.js.map +1 -1
- package/src/lib/constants/bundle-common.d.ts +12 -0
- package/src/lib/constants/bundle-common.js +13 -0
- package/src/lib/constants/bundle-common.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +16 -1
- package/src/lib/declarations.console-server.js +34 -2
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/declarations.server.d.ts +13 -0
- package/src/lib/declarations.server.js +48 -2
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/form-target/dataset-form-record-target.server.js +5 -3
- package/src/lib/form-target/dataset-form-record-target.server.js.map +1 -1
- package/src/lib/model/dataset-models.d.ts +392 -0
- package/src/lib/model/dataset-models.js +806 -0
- package/src/lib/model/dataset-models.js.map +1 -0
- package/src/lib/model/dataset-record-view.d.ts +1 -1
- package/src/lib/model/dataset-record-view.js +1 -2
- package/src/lib/model/dataset-record-view.js.map +1 -1
- package/src/lib/model/dataset-record.js +2 -2
- package/src/lib/model/dataset-record.js.map +1 -1
- package/src/lib/model/datasets.d.ts +162 -0
- package/src/lib/model/datasets.js +227 -0
- package/src/lib/model/datasets.js.map +1 -0
- package/src/lib/plugin.d.ts +4 -0
- package/src/lib/plugin.js +40 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/record-pages/record-page-live-paths.server.d.ts +55 -0
- package/src/lib/record-pages/record-page-live-paths.server.js +83 -0
- package/src/lib/record-pages/record-page-live-paths.server.js.map +1 -0
- package/src/lib/record-pages/record-page-read.server.d.ts +70 -0
- package/src/lib/record-pages/record-page-read.server.js +175 -0
- package/src/lib/record-pages/record-page-read.server.js.map +1 -0
- package/src/lib/record-pages/record-page-resolver.server.d.ts +37 -0
- package/src/lib/record-pages/record-page-resolver.server.js +131 -0
- package/src/lib/record-pages/record-page-resolver.server.js.map +1 -0
- package/src/lib/record-pages/record-page-sitemap.server.d.ts +32 -0
- package/src/lib/record-pages/record-page-sitemap.server.js +102 -0
- package/src/lib/record-pages/record-page-sitemap.server.js.map +1 -0
- package/src/lib/record-pages/record-page-source.d.ts +42 -0
- package/src/lib/record-pages/record-page-source.js +176 -0
- package/src/lib/record-pages/record-page-source.js.map +1 -0
- package/src/lib/record-pages/record-pages-route.d.ts +42 -0
- package/src/lib/record-pages/record-pages-route.js +348 -0
- package/src/lib/record-pages/record-pages-route.js.map +1 -0
- package/src/lib/record-pages/record-pages.d.ts +218 -0
- package/src/lib/record-pages/record-pages.js +351 -0
- package/src/lib/record-pages/record-pages.js.map +1 -0
- package/src/lib/record-pages/record-template-row-chip.component.d.ts +28 -0
- package/src/lib/record-pages/record-template-row-chip.component.js +87 -0
- package/src/lib/record-pages/record-template-row-chip.component.js.map +1 -0
- package/src/lib/record-pages/record-template-section.component.d.ts +30 -0
- package/src/lib/record-pages/record-template-section.component.js +491 -0
- package/src/lib/record-pages/record-template-section.component.js.map +1 -0
- package/src/lib/repeat/dataset-repeat-rows.d.ts +2 -1
- package/src/lib/repeat/dataset-repeat-rows.js +30 -12
- package/src/lib/repeat/dataset-repeat-rows.js.map +1 -1
- package/src/lib/repeat/dataset-repeat-rows.server.d.ts +1 -1
- package/src/lib/repeat/dataset-repeat-rows.server.js +23 -12
- package/src/lib/repeat/dataset-repeat-rows.server.js.map +1 -1
- package/src/lib/repeat/repeat-record-pages.d.ts +48 -0
- package/src/lib/repeat/repeat-record-pages.js +64 -0
- package/src/lib/repeat/repeat-record-pages.js.map +1 -0
- package/src/lib/server/announce-dataset-records.d.ts +1 -1
- package/src/lib/server/announce-dataset-records.js +1 -1
- package/src/lib/server/announce-dataset-records.js.map +1 -1
- package/src/lib/server/api-v1/datasets.js +5 -3
- package/src/lib/server/api-v1/datasets.js.map +1 -1
- package/src/lib/server/dataset-figures.js +2 -2
- package/src/lib/server/dataset-figures.js.map +1 -1
- package/src/lib/server/dataset-live-pages.d.ts +147 -0
- package/src/lib/server/dataset-live-pages.js +436 -0
- package/src/lib/server/dataset-live-pages.js.map +1 -0
- package/src/lib/server/dataset-steps.server.d.ts +45 -0
- package/src/lib/server/dataset-steps.server.js +268 -0
- package/src/lib/server/dataset-steps.server.js.map +1 -0
- package/src/lib/server/datasets-route.d.ts +2 -0
- package/src/lib/server/datasets-route.js +102 -6
- package/src/lib/server/datasets-route.js.map +1 -1
- package/src/lib/server/resolve-dataset.d.ts +40 -0
- package/src/lib/server/resolve-dataset.js +64 -0
- package/src/lib/server/resolve-dataset.js.map +1 -0
- package/src/lib/server.d.ts +3 -2
- package/src/lib/server.js +8 -9
- package/src/lib/server.js.map +1 -1
- package/src/lib/site-bundle/datasets-package.d.ts +42 -0
- package/src/lib/site-bundle/datasets-package.js +95 -0
- package/src/lib/site-bundle/datasets-package.js.map +1 -0
- package/src/lib/site-bundle/datasets-site-bundle.server.js +2 -2
- package/src/lib/site-bundle/datasets-site-bundle.server.js.map +1 -1
- package/src/lib/{model/index.js → tenant-api.d.ts} +12 -7
- package/src/lib/tenant-api.js +30 -0
- package/src/lib/tenant-api.js.map +1 -0
- package/src/lib/transfer/dataset-transfer-key.d.ts +30 -0
- package/src/lib/transfer/dataset-transfer-key.js +31 -0
- package/src/lib/transfer/dataset-transfer-key.js.map +1 -0
- package/src/lib/transfer/dataset-transfer-model.d.ts +146 -0
- package/src/lib/transfer/dataset-transfer-model.js +536 -0
- package/src/lib/transfer/dataset-transfer-model.js.map +1 -0
- package/src/lib/{model/index.d.ts → transfer/dataset-transfer-resource.d.ts} +2 -6
- package/src/lib/transfer/dataset-transfer-resource.js +41 -0
- package/src/lib/transfer/dataset-transfer-resource.js.map +1 -0
- package/src/lib/transfer/dataset-transfer.server.d.ts +69 -0
- package/src/lib/transfer/dataset-transfer.server.js +930 -0
- package/src/lib/transfer/dataset-transfer.server.js.map +1 -0
- package/src/lib/model/dataset-csv.d.ts +0 -36
- package/src/lib/model/dataset-csv.js +0 -85
- package/src/lib/model/dataset-csv.js.map +0 -1
- package/src/lib/model/dataset-io.d.ts +0 -44
- package/src/lib/model/dataset-io.js +0 -85
- package/src/lib/model/dataset-io.js.map +0 -1
- package/src/lib/model/index.js.map +0 -1
- package/src/lib/server/datasets-export-route.d.ts +0 -57
- package/src/lib/server/datasets-export-route.js +0 -229
- package/src/lib/server/datasets-export-route.js.map +0 -1
|
@@ -1,229 +0,0 @@
|
|
|
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
|
|
@@ -1 +0,0 @@
|
|
|
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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"}
|