@nonext/husk 0.2.3
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/CHANGELOG.md +148 -0
- package/LICENSE +166 -0
- package/README.md +140 -0
- package/api/public-api.md +1070 -0
- package/dist/adapter-C_0mhENy.d.ts +320 -0
- package/dist/admin/admin.css +5 -0
- package/dist/admin/index.d.ts +265 -0
- package/dist/admin/index.js +393 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/tokens.css +597 -0
- package/dist/admin/ui.d.ts +3013 -0
- package/dist/admin/ui.js +9783 -0
- package/dist/admin/ui.js.map +1 -0
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +9 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth-B2gBtHl5.d.ts +28 -0
- package/dist/can-Chy7zmx7.d.ts +69 -0
- package/dist/chunk-3I6337WJ.js +83 -0
- package/dist/chunk-3I6337WJ.js.map +1 -0
- package/dist/chunk-4Z6P3XCE.js +62 -0
- package/dist/chunk-4Z6P3XCE.js.map +1 -0
- package/dist/chunk-6E3ZS5Z5.js +1007 -0
- package/dist/chunk-6E3ZS5Z5.js.map +1 -0
- package/dist/chunk-77JDRXXU.js +3 -0
- package/dist/chunk-77JDRXXU.js.map +1 -0
- package/dist/chunk-FHYWJCG6.js +171 -0
- package/dist/chunk-FHYWJCG6.js.map +1 -0
- package/dist/chunk-JAE7HHMM.js +506 -0
- package/dist/chunk-JAE7HHMM.js.map +1 -0
- package/dist/chunk-NPDBQBLW.js +68 -0
- package/dist/chunk-NPDBQBLW.js.map +1 -0
- package/dist/chunk-ORMYARXD.js +6 -0
- package/dist/chunk-ORMYARXD.js.map +1 -0
- package/dist/chunk-R4O7APIX.js +51 -0
- package/dist/chunk-R4O7APIX.js.map +1 -0
- package/dist/chunk-ROLKRJWI.js +187 -0
- package/dist/chunk-ROLKRJWI.js.map +1 -0
- package/dist/chunk-SOTQ3WP5.js +31 -0
- package/dist/chunk-SOTQ3WP5.js.map +1 -0
- package/dist/chunk-TCFFHTY3.js +97 -0
- package/dist/chunk-TCFFHTY3.js.map +1 -0
- package/dist/chunk-TS7Y7GEZ.js +7 -0
- package/dist/chunk-TS7Y7GEZ.js.map +1 -0
- package/dist/chunk-VLGR5PZX.js +3 -0
- package/dist/chunk-VLGR5PZX.js.map +1 -0
- package/dist/chunk-ZW2PUSVS.js +40 -0
- package/dist/chunk-ZW2PUSVS.js.map +1 -0
- package/dist/chunk-ZYNBMYJ6.js +392 -0
- package/dist/chunk-ZYNBMYJ6.js.map +1 -0
- package/dist/cli/nonext-husk.js +3360 -0
- package/dist/cli/nonext-husk.js.map +1 -0
- package/dist/cli/templates/firestore.rules +393 -0
- package/dist/cli/templates/storage.rules +165 -0
- package/dist/client/index.d.ts +27 -0
- package/dist/client/index.js +15 -0
- package/dist/client/index.js.map +1 -0
- package/dist/cms-9fSA3uQV.d.ts +158 -0
- package/dist/core/index.d.ts +1267 -0
- package/dist/core/index.js +12 -0
- package/dist/core/index.js.map +1 -0
- package/dist/entry-D3kiE8T1.d.ts +163 -0
- package/dist/field-Diyf5siF.d.ts +158 -0
- package/dist/field-type-D25Vojb0.d.ts +157 -0
- package/dist/firebase/index.d.ts +251 -0
- package/dist/firebase/index.js +991 -0
- package/dist/firebase/index.js.map +1 -0
- package/dist/index-5ylaMBUH.d.ts +155 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/permissions/index.d.ts +101 -0
- package/dist/permissions/index.js +7 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/registry-CUC25qay.d.ts +59 -0
- package/dist/richtext/index.d.ts +85 -0
- package/dist/richtext/index.js +127 -0
- package/dist/richtext/index.js.map +1 -0
- package/dist/schema/index.d.ts +50 -0
- package/dist/schema/index.js +5 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/server/index.d.ts +39 -0
- package/dist/server/index.js +25 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- package/dist/write-limits-Djmm6L3R.d.ts +215 -0
- package/package.json +136 -0
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
import { validateEntry, uniqueFieldPaths } from '../chunk-FHYWJCG6.js';
|
|
2
|
+
import { ENTRY_STATUSES } from '../chunk-ORMYARXD.js';
|
|
3
|
+
import { entryPath, metaFromStored, entryFromStored, storageObjectPath, entriesPath, valuesToStored, ENTRIES, MEDIA, mediaPath, schemaPath, contentPath, SCHEMAS, BOOTSTRAP_KEY, settingsPath, toIso } from '../chunk-ZYNBMYJ6.js';
|
|
4
|
+
export { AuthError, ClaimSyncError, createEmulatorClaimSync, createHuskAuth, createUnavailableClaimSync, patchUser, readUser, toAuthUser, writeUser } from '../chunk-ZYNBMYJ6.js';
|
|
5
|
+
import { createWriteRateLimiter, slugFieldKey, normalizeLimit, safeFileName, MEDIA_REFS_KEY, collectMediaIds, retargetMediaUrls, MEDIA_USAGE_LIMIT, nextSchemaVersion, schemaVersionOf, MAX_FIELD_PURGE, checkWritePayload, MAX_ENTRY_FIELDS, checkMediaFile, normalizeMediaType, assertSchemaWritable, MAX_CONTENT_TYPE_FIELDS, mediaTypeRule, MAX_SETTINGS_FIELDS } from '../chunk-JAE7HHMM.js';
|
|
6
|
+
import { assertValidContentType, assertTypeSlugAvailable } from '../chunk-ROLKRJWI.js';
|
|
7
|
+
import '../chunk-SOTQ3WP5.js';
|
|
8
|
+
import { assertDeletable, assertSingletonEntryId, isSingleton, singletonEntryId, assertSingletonCreatable, assertListable } from '../chunk-R4O7APIX.js';
|
|
9
|
+
import { normalizePermissionMatrix } from '../chunk-TCFFHTY3.js';
|
|
10
|
+
import { can, resolveDecision } from '../chunk-NPDBQBLW.js';
|
|
11
|
+
import '../chunk-TS7Y7GEZ.js';
|
|
12
|
+
import { SchemaError, PermissionError, ValidationError, WriteLimitError } from '../chunk-4Z6P3XCE.js';
|
|
13
|
+
import { where, getCountFromServer, query, doc, getDoc, deleteDoc, limit, getDocs, startAfter, collection, orderBy, setDoc, serverTimestamp, updateDoc, collectionGroup, getAggregateFromServer, sum, count, onSnapshot, writeBatch, deleteField, runTransaction } from 'firebase/firestore';
|
|
14
|
+
import { deleteObject, ref, uploadBytesResumable, getDownloadURL } from 'firebase/storage';
|
|
15
|
+
|
|
16
|
+
var MAX_MEDIA_REFRESH = 500;
|
|
17
|
+
var DEFAULT_SORT = { field: "updatedAt", direction: "desc" };
|
|
18
|
+
var PREFIX_END = "\uF8FF";
|
|
19
|
+
function searchRange(type, search) {
|
|
20
|
+
const term = (search ?? "").trim();
|
|
21
|
+
if (term === "") return null;
|
|
22
|
+
const field = type.titleField;
|
|
23
|
+
if (field === void 0) {
|
|
24
|
+
throw new SchemaError(
|
|
25
|
+
`Content type "${type.slug}" has no title field, so its entries cannot be searched.`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return { field, term };
|
|
29
|
+
}
|
|
30
|
+
function assertTopLevelKey(slug, key) {
|
|
31
|
+
if (key.includes(".") || key.length === 0) {
|
|
32
|
+
throw new SchemaError(
|
|
33
|
+
`Field key "${key}" of content type "${slug}" is not a top-level field. The values of a field nested inside a repeater are counted and purged with its row, not on their own.`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function fieldError(path, rule, message) {
|
|
38
|
+
return { path, rule, message };
|
|
39
|
+
}
|
|
40
|
+
function createFirestoreAdapter(deps) {
|
|
41
|
+
const { db, registry, currentUser, fieldTypes } = deps;
|
|
42
|
+
const limiter = deps.rateLimiter === void 0 ? createWriteRateLimiter() : deps.rateLimiter;
|
|
43
|
+
function resolveType(slug) {
|
|
44
|
+
return registry().require(slug).type;
|
|
45
|
+
}
|
|
46
|
+
function assertCan(user, action, context = {}) {
|
|
47
|
+
if (can(user, action, context)) return;
|
|
48
|
+
throw new PermissionError(action, context.type?.slug);
|
|
49
|
+
}
|
|
50
|
+
function assertNotDenied(user, action, type) {
|
|
51
|
+
if (user.role === "admin") return;
|
|
52
|
+
if (resolveDecision(user.role, action, type) !== "deny") return;
|
|
53
|
+
throw new PermissionError(action, type.slug);
|
|
54
|
+
}
|
|
55
|
+
function assertCanRead(type) {
|
|
56
|
+
const user = deps.session?.() ?? null;
|
|
57
|
+
if (user === null) return;
|
|
58
|
+
assertCan(user, "read", { type });
|
|
59
|
+
}
|
|
60
|
+
function refuse(rejection) {
|
|
61
|
+
if (rejection !== null) throw new WriteLimitError(rejection);
|
|
62
|
+
}
|
|
63
|
+
function guardWrite(values, maxFields) {
|
|
64
|
+
refuse(checkWritePayload(values, { maxFields }));
|
|
65
|
+
if (limiter !== null) refuse(limiter.check());
|
|
66
|
+
}
|
|
67
|
+
function guardSchemaWrite(type) {
|
|
68
|
+
guardWrite(type.fields, MAX_CONTENT_TYPE_FIELDS);
|
|
69
|
+
}
|
|
70
|
+
function assertMediaInput(input, mediaId) {
|
|
71
|
+
const rejection = checkMediaFile({
|
|
72
|
+
name: input.name,
|
|
73
|
+
type: input.type,
|
|
74
|
+
size: input.size
|
|
75
|
+
});
|
|
76
|
+
if (rejection !== null) {
|
|
77
|
+
throw new ValidationError([fieldError("file", rejection.code, rejection.message)]);
|
|
78
|
+
}
|
|
79
|
+
if (!input.path.startsWith(`${MEDIA}/${mediaId}/`)) {
|
|
80
|
+
throw new ValidationError([
|
|
81
|
+
fieldError(
|
|
82
|
+
"path",
|
|
83
|
+
"media-path",
|
|
84
|
+
`A media object path must start with "${MEDIA}/${mediaId}/", and "${input.path}" does not.`
|
|
85
|
+
)
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
if (input.url.trim() === "") {
|
|
89
|
+
throw new ValidationError([
|
|
90
|
+
fieldError("url", "required", "A media document needs a download url.")
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function withNormalizedMatrix(type) {
|
|
95
|
+
const matrix = normalizePermissionMatrix(type.permissions);
|
|
96
|
+
const { permissions: _dropped, ...rest } = type;
|
|
97
|
+
return matrix === void 0 ? rest : { ...rest, permissions: matrix };
|
|
98
|
+
}
|
|
99
|
+
function assertStatus(status) {
|
|
100
|
+
if (!ENTRY_STATUSES.includes(status)) {
|
|
101
|
+
throw new SchemaError(
|
|
102
|
+
`Entry status "${status}" is not one of: ${ENTRY_STATUSES.join(", ")}.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function entriesCollection(slug) {
|
|
107
|
+
return collection(db, entriesPath(slug));
|
|
108
|
+
}
|
|
109
|
+
async function snapshotFor(slug, entryId) {
|
|
110
|
+
return getDoc(doc(db, entryPath(slug, entryId)));
|
|
111
|
+
}
|
|
112
|
+
function toEntry(type, snapshot) {
|
|
113
|
+
return entryFromStored(type, snapshot.id, snapshot.data(), fieldTypes);
|
|
114
|
+
}
|
|
115
|
+
function constraintsFor(type, listQuery) {
|
|
116
|
+
const constraints = [];
|
|
117
|
+
const status = listQuery.status ?? "any";
|
|
118
|
+
if (status !== "any") {
|
|
119
|
+
constraints.push(where("status", "==", status));
|
|
120
|
+
}
|
|
121
|
+
for (const filter of listQuery.filters ?? []) {
|
|
122
|
+
constraints.push(where(filter.field, filter.op, filter.value));
|
|
123
|
+
}
|
|
124
|
+
const range = searchRange(type, listQuery.search);
|
|
125
|
+
if (range !== null) {
|
|
126
|
+
constraints.push(where(range.field, ">=", range.term));
|
|
127
|
+
constraints.push(where(range.field, "<=", `${range.term}${PREFIX_END}`));
|
|
128
|
+
}
|
|
129
|
+
const sort = range === null ? listQuery.sort ?? DEFAULT_SORT : { field: range.field, direction: "asc" };
|
|
130
|
+
constraints.push(orderBy(sort.field, sort.direction));
|
|
131
|
+
if (sort.field !== "__name__") {
|
|
132
|
+
constraints.push(orderBy("__name__", sort.direction));
|
|
133
|
+
}
|
|
134
|
+
return constraints;
|
|
135
|
+
}
|
|
136
|
+
async function assertUnique(type, values, selfId) {
|
|
137
|
+
const keys = uniqueFieldPaths(type);
|
|
138
|
+
if (keys.length === 0) return;
|
|
139
|
+
const errors = [];
|
|
140
|
+
for (const key of keys) {
|
|
141
|
+
const value = values[key];
|
|
142
|
+
if (value === null || value === void 0 || value === "") continue;
|
|
143
|
+
const found = await getDocs(
|
|
144
|
+
query(entriesCollection(type.slug), where(key, "==", value), limit(2))
|
|
145
|
+
);
|
|
146
|
+
const taken = found.docs.some((candidate) => candidate.id !== selfId);
|
|
147
|
+
if (taken) {
|
|
148
|
+
const label = type.fields[key]?.label ?? key;
|
|
149
|
+
errors.push(
|
|
150
|
+
fieldError(
|
|
151
|
+
key,
|
|
152
|
+
"unique",
|
|
153
|
+
`${label} must be unique, and "${String(value)}" is taken.`
|
|
154
|
+
)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (errors.length > 0) throw new ValidationError(errors);
|
|
159
|
+
}
|
|
160
|
+
async function ensureContentDoc(type) {
|
|
161
|
+
const reference = doc(db, contentPath(type.slug));
|
|
162
|
+
const stored = await getDoc(reference);
|
|
163
|
+
const matrix = normalizePermissionMatrix(type.permissions);
|
|
164
|
+
if (stored.exists()) {
|
|
165
|
+
await syncContentMeta(reference, stored.data(), type, matrix);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
await setDoc(reference, {
|
|
170
|
+
slug: type.slug,
|
|
171
|
+
kind: type.kind,
|
|
172
|
+
locked: type.locked ?? false,
|
|
173
|
+
schemaVersion: type.schemaVersion ?? 1,
|
|
174
|
+
// The rules read the matrix from here, not from `schemas/{slug}`, because a
|
|
175
|
+
// code-defined type has no document under `schemas/` at all and its matrix still
|
|
176
|
+
// has to reach the rules. Stored as an empty map rather than omitted so the two
|
|
177
|
+
// states a rule can see, "not narrowed" and "narrowed to nobody", stay distinct.
|
|
178
|
+
permissions: matrix ?? {},
|
|
179
|
+
updatedAt: serverTimestamp()
|
|
180
|
+
});
|
|
181
|
+
} catch (thrown) {
|
|
182
|
+
if (thrown.code !== "permission-denied") throw thrown;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async function syncContentMeta(reference, stored, type, matrix) {
|
|
186
|
+
const next = matrix ?? {};
|
|
187
|
+
if (sameMatrix(stored["permissions"], next)) return;
|
|
188
|
+
try {
|
|
189
|
+
await updateDoc(reference, { permissions: next, updatedAt: serverTimestamp() });
|
|
190
|
+
} catch (thrown) {
|
|
191
|
+
if (thrown.code !== "permission-denied") throw thrown;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function writeEntry(type, entryId, input, mode) {
|
|
195
|
+
const status = input.status ?? "draft";
|
|
196
|
+
const user = currentUser();
|
|
197
|
+
const reference = doc(db, entryPath(type.slug, entryId));
|
|
198
|
+
if (mode === "update") assertNotDenied(user, "update", type);
|
|
199
|
+
const existing = mode === "update" ? await getDoc(reference) : null;
|
|
200
|
+
if (mode === "create") {
|
|
201
|
+
assertCan(user, "create", { type });
|
|
202
|
+
} else {
|
|
203
|
+
const owner = existing?.exists() === true ? metaFromStored(entryId, existing.data()) : void 0;
|
|
204
|
+
assertCan(user, "update", {
|
|
205
|
+
type,
|
|
206
|
+
...owner === void 0 ? {} : { entry: { createdBy: owner.createdBy } }
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
assertStatus(status);
|
|
210
|
+
guardWrite(input.values, MAX_ENTRY_FIELDS);
|
|
211
|
+
const errors = validateEntry(type, input.values, {
|
|
212
|
+
skipRequired: status === "draft",
|
|
213
|
+
...fieldTypes === void 0 ? {} : { fieldTypes }
|
|
214
|
+
});
|
|
215
|
+
if (errors.length > 0) throw new ValidationError(errors);
|
|
216
|
+
await assertUnique(type, input.values, mode === "update" ? entryId : null);
|
|
217
|
+
const stored = valuesToStored(type.fields, input.values, fieldTypes);
|
|
218
|
+
const mediaRefs = { [MEDIA_REFS_KEY]: collectMediaIds(stored) };
|
|
219
|
+
if (mode === "create") {
|
|
220
|
+
await ensureContentDoc(type);
|
|
221
|
+
await setDoc(reference, {
|
|
222
|
+
...stored,
|
|
223
|
+
...mediaRefs,
|
|
224
|
+
status,
|
|
225
|
+
schemaVersion: type.schemaVersion ?? 1,
|
|
226
|
+
createdAt: serverTimestamp(),
|
|
227
|
+
createdBy: user.uid,
|
|
228
|
+
updatedAt: serverTimestamp(),
|
|
229
|
+
updatedBy: user.uid,
|
|
230
|
+
publishedAt: status === "published" ? serverTimestamp() : null
|
|
231
|
+
});
|
|
232
|
+
} else {
|
|
233
|
+
if (existing === null || !existing.exists()) {
|
|
234
|
+
throw new SchemaError(
|
|
235
|
+
`Entry "${entryId}" of content type "${type.slug}" does not exist.`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
const previous = metaFromStored(entryId, existing.data());
|
|
239
|
+
await updateDoc(reference, {
|
|
240
|
+
...stored,
|
|
241
|
+
...mediaRefs,
|
|
242
|
+
status,
|
|
243
|
+
schemaVersion: type.schemaVersion ?? 1,
|
|
244
|
+
updatedAt: serverTimestamp(),
|
|
245
|
+
updatedBy: user.uid,
|
|
246
|
+
// First publish stamps the date; republishing keeps the original, and unpublishing
|
|
247
|
+
// clears it, so publishedAt always answers "since when has this been public".
|
|
248
|
+
publishedAt: status === "published" ? previous.publishedAt ?? serverTimestamp() : status === "draft" ? null : previous.publishedAt
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const written = await getDoc(reference);
|
|
252
|
+
return entryFromStored(type, entryId, written.data() ?? {}, fieldTypes);
|
|
253
|
+
}
|
|
254
|
+
function usageTitle(slug, data) {
|
|
255
|
+
let titleField;
|
|
256
|
+
try {
|
|
257
|
+
titleField = resolveType(slug).titleField;
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
if (titleField === void 0) return null;
|
|
262
|
+
const value = data[titleField];
|
|
263
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
264
|
+
return value.trim();
|
|
265
|
+
}
|
|
266
|
+
const media = {
|
|
267
|
+
async list(mediaQuery = {}) {
|
|
268
|
+
const size = normalizeLimit(mediaQuery.limit);
|
|
269
|
+
const constraints = [];
|
|
270
|
+
if (mediaQuery.type !== void 0) {
|
|
271
|
+
constraints.push(where("type", ">=", mediaQuery.type));
|
|
272
|
+
constraints.push(where("type", "<", `${mediaQuery.type}\uF8FF`));
|
|
273
|
+
constraints.push(orderBy("type", "asc"));
|
|
274
|
+
}
|
|
275
|
+
constraints.push(orderBy("createdAt", "desc"), orderBy("__name__", "desc"));
|
|
276
|
+
if (mediaQuery.cursor) {
|
|
277
|
+
const anchor = await getDoc(doc(db, mediaPath(mediaQuery.cursor)));
|
|
278
|
+
if (anchor.exists()) constraints.push(startAfter(anchor));
|
|
279
|
+
}
|
|
280
|
+
constraints.push(limit(size + 1));
|
|
281
|
+
const found = await getDocs(query(collection(db, MEDIA), ...constraints));
|
|
282
|
+
const docs = found.docs.slice(0, size);
|
|
283
|
+
return {
|
|
284
|
+
items: docs.map((snapshot) => mediaFromStored(snapshot.id, snapshot.data())),
|
|
285
|
+
cursor: found.docs.length > size ? docs.at(-1)?.id ?? null : null,
|
|
286
|
+
hasMore: found.docs.length > size
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
async getById(mediaId) {
|
|
290
|
+
const snapshot = await getDoc(doc(db, mediaPath(mediaId)));
|
|
291
|
+
return snapshot.exists() ? mediaFromStored(snapshot.id, snapshot.data()) : null;
|
|
292
|
+
},
|
|
293
|
+
async getMany(mediaIds) {
|
|
294
|
+
const found = await Promise.all(mediaIds.map((mediaId) => media.getById(mediaId)));
|
|
295
|
+
return found.filter((item) => item !== null);
|
|
296
|
+
},
|
|
297
|
+
reserveId() {
|
|
298
|
+
return doc(collection(db, MEDIA)).id;
|
|
299
|
+
},
|
|
300
|
+
async create(input, mediaId) {
|
|
301
|
+
const reference = mediaId === void 0 ? doc(collection(db, MEDIA)) : doc(db, mediaPath(mediaId));
|
|
302
|
+
const user = currentUser();
|
|
303
|
+
assertMediaInput(input, reference.id);
|
|
304
|
+
if (limiter !== null) refuse(limiter.check());
|
|
305
|
+
await setDoc(reference, {
|
|
306
|
+
name: input.name,
|
|
307
|
+
type: input.type,
|
|
308
|
+
size: input.size,
|
|
309
|
+
url: input.url,
|
|
310
|
+
path: input.path,
|
|
311
|
+
alt: input.alt ?? null,
|
|
312
|
+
width: input.width ?? null,
|
|
313
|
+
height: input.height ?? null,
|
|
314
|
+
createdAt: serverTimestamp(),
|
|
315
|
+
createdBy: user.uid
|
|
316
|
+
});
|
|
317
|
+
const written = await getDoc(reference);
|
|
318
|
+
return mediaFromStored(reference.id, written.data() ?? {});
|
|
319
|
+
},
|
|
320
|
+
async update(mediaId, patch) {
|
|
321
|
+
const reference = doc(db, mediaPath(mediaId));
|
|
322
|
+
const current = await media.getById(mediaId);
|
|
323
|
+
if (current === null) {
|
|
324
|
+
throw new SchemaError(`Media item "${mediaId}" does not exist.`);
|
|
325
|
+
}
|
|
326
|
+
assertMediaInput({ ...current, ...patch }, mediaId);
|
|
327
|
+
if (limiter !== null) refuse(limiter.check());
|
|
328
|
+
await updateDoc(reference, { ...patch });
|
|
329
|
+
const written = await getDoc(reference);
|
|
330
|
+
return mediaFromStored(mediaId, written.data() ?? {});
|
|
331
|
+
},
|
|
332
|
+
async delete(mediaId) {
|
|
333
|
+
await deleteDoc(doc(db, mediaPath(mediaId)));
|
|
334
|
+
},
|
|
335
|
+
async usage() {
|
|
336
|
+
const snapshot = await getAggregateFromServer(collection(db, MEDIA), {
|
|
337
|
+
count: count(),
|
|
338
|
+
bytes: sum("size")
|
|
339
|
+
});
|
|
340
|
+
const data = snapshot.data();
|
|
341
|
+
return { count: data.count, bytes: data.bytes };
|
|
342
|
+
},
|
|
343
|
+
/**
|
|
344
|
+
* Entries referencing this media item, from the reverse index.
|
|
345
|
+
*
|
|
346
|
+
* One collection-group query over `entries`, answered by the automatic single-field
|
|
347
|
+
* index on the `mediaRefs` array. The cost is the number of referencing entries, not
|
|
348
|
+
* the size of the database, which is the whole reason the index is written: the
|
|
349
|
+
* alternative was reading every entry of every content type on every delete.
|
|
350
|
+
*
|
|
351
|
+
* The blind spot is named rather than hidden: an entry written before the index
|
|
352
|
+
* existed, or written straight into the console, has no `mediaRefs` field and does not
|
|
353
|
+
* appear here until it is saved once. See core/media-references.ts.
|
|
354
|
+
*/
|
|
355
|
+
async usages(mediaId, usageQuery = {}) {
|
|
356
|
+
const size = normalizeLimit(usageQuery.limit ?? MEDIA_USAGE_LIMIT);
|
|
357
|
+
const found = await getDocs(
|
|
358
|
+
query(
|
|
359
|
+
collectionGroup(db, ENTRIES),
|
|
360
|
+
where(MEDIA_REFS_KEY, "array-contains", mediaId),
|
|
361
|
+
limit(size)
|
|
362
|
+
)
|
|
363
|
+
);
|
|
364
|
+
return found.docs.map((snapshot) => {
|
|
365
|
+
const contentType = snapshot.ref.parent.parent?.id ?? "";
|
|
366
|
+
return {
|
|
367
|
+
contentType,
|
|
368
|
+
entryId: snapshot.id,
|
|
369
|
+
title: usageTitle(contentType, snapshot.data())
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
},
|
|
373
|
+
/**
|
|
374
|
+
* Points every referencing entry at the new url after a replace.
|
|
375
|
+
*
|
|
376
|
+
* Best effort by construction. Each entry is its own write, an author may hold no
|
|
377
|
+
* permission over somebody else's entry, and a failure here costs a stale convenience
|
|
378
|
+
* rather than a broken reference, because `mediaId` is the source of truth and the
|
|
379
|
+
* admin resolves through it. What fails is returned by name; nothing is swallowed.
|
|
380
|
+
*/
|
|
381
|
+
async refreshReferences(mediaId, url) {
|
|
382
|
+
const references = await media.usages(mediaId, { limit: MAX_MEDIA_REFRESH });
|
|
383
|
+
const user = currentUser();
|
|
384
|
+
const failed = [];
|
|
385
|
+
let updated = 0;
|
|
386
|
+
for (const reference of references) {
|
|
387
|
+
try {
|
|
388
|
+
const target = doc(db, entryPath(reference.contentType, reference.entryId));
|
|
389
|
+
const snapshot = await getDoc(target);
|
|
390
|
+
if (!snapshot.exists()) continue;
|
|
391
|
+
const rewritten = retargetMediaUrls(snapshot.data(), mediaId, url);
|
|
392
|
+
if (rewritten === null) continue;
|
|
393
|
+
await updateDoc(target, {
|
|
394
|
+
...rewritten,
|
|
395
|
+
updatedAt: serverTimestamp(),
|
|
396
|
+
updatedBy: user.uid
|
|
397
|
+
});
|
|
398
|
+
updated += 1;
|
|
399
|
+
} catch {
|
|
400
|
+
failed.push(reference);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return { updated, failed };
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
async function assertSchemaWrite(slug) {
|
|
407
|
+
const current = registry();
|
|
408
|
+
if (current.has(slug)) {
|
|
409
|
+
assertSchemaWritable(current, slug);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (!(await getDoc(doc(db, schemaPath(slug)))).exists()) {
|
|
413
|
+
throw new SchemaError(`Unknown content type "${slug}".`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const schemas = {
|
|
417
|
+
async list() {
|
|
418
|
+
const found = await getDocs(collection(db, SCHEMAS));
|
|
419
|
+
return found.docs.map((snapshot) => snapshot.data());
|
|
420
|
+
},
|
|
421
|
+
async get(slug) {
|
|
422
|
+
const snapshot = await getDoc(doc(db, schemaPath(slug)));
|
|
423
|
+
return snapshot.exists() ? snapshot.data() : null;
|
|
424
|
+
},
|
|
425
|
+
watch(listener, onError) {
|
|
426
|
+
return onSnapshot(
|
|
427
|
+
collection(db, SCHEMAS),
|
|
428
|
+
(found) => {
|
|
429
|
+
listener(found.docs.map((snapshot) => snapshot.data()));
|
|
430
|
+
},
|
|
431
|
+
(error) => {
|
|
432
|
+
onError?.(error);
|
|
433
|
+
}
|
|
434
|
+
);
|
|
435
|
+
},
|
|
436
|
+
async create(type) {
|
|
437
|
+
assertCan(currentUser(), "manage-schema");
|
|
438
|
+
assertTypeSlugAvailable(registry(), type.slug);
|
|
439
|
+
assertValidContentType(type);
|
|
440
|
+
guardSchemaWrite(type);
|
|
441
|
+
const reference = doc(db, schemaPath(type.slug));
|
|
442
|
+
if ((await getDoc(reference)).exists()) {
|
|
443
|
+
throw new SchemaError(
|
|
444
|
+
`Content type "${type.slug}" already exists in the database.`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
const created = {
|
|
448
|
+
...withNormalizedMatrix(type),
|
|
449
|
+
schemaVersion: nextSchemaVersion(null, type)
|
|
450
|
+
};
|
|
451
|
+
await setDoc(reference, { ...created, updatedAt: serverTimestamp() });
|
|
452
|
+
await ensureContentDoc(created);
|
|
453
|
+
return created;
|
|
454
|
+
},
|
|
455
|
+
async update(slug, type) {
|
|
456
|
+
assertCan(currentUser(), "manage-schema");
|
|
457
|
+
await assertSchemaWrite(slug);
|
|
458
|
+
assertValidContentType(type);
|
|
459
|
+
guardSchemaWrite(type);
|
|
460
|
+
if (type.slug !== slug) {
|
|
461
|
+
throw new SchemaError(
|
|
462
|
+
`Content type "${slug}" cannot be renamed to "${type.slug}" in place. A slug change moves every entry and is a migration.`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
const reference = doc(db, schemaPath(slug));
|
|
466
|
+
const stored = await getDoc(reference);
|
|
467
|
+
const previous = stored.exists() ? stored.data() : null;
|
|
468
|
+
const written = {
|
|
469
|
+
...withNormalizedMatrix(type),
|
|
470
|
+
schemaVersion: nextSchemaVersion(previous, type)
|
|
471
|
+
};
|
|
472
|
+
await setDoc(reference, { ...written, updatedAt: serverTimestamp() });
|
|
473
|
+
await ensureContentDoc(written);
|
|
474
|
+
if (previous !== null && schemaVersionOf(previous) !== written.schemaVersion) {
|
|
475
|
+
await updateDoc(doc(db, contentPath(slug)), {
|
|
476
|
+
schemaVersion: written.schemaVersion,
|
|
477
|
+
updatedAt: serverTimestamp()
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return written;
|
|
481
|
+
},
|
|
482
|
+
async delete(slug) {
|
|
483
|
+
assertCan(currentUser(), "manage-schema");
|
|
484
|
+
await assertSchemaWrite(slug);
|
|
485
|
+
await deleteDoc(doc(db, schemaPath(slug)));
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
const fieldData = {
|
|
489
|
+
async count(slug, key) {
|
|
490
|
+
assertTopLevelKey(slug, key);
|
|
491
|
+
const type = resolveType(slug);
|
|
492
|
+
if (!(key in type.fields) && type.removedFields?.[key] === void 0) {
|
|
493
|
+
throw new SchemaError(
|
|
494
|
+
`Content type "${type.slug}" has no field "${key}", live or removed.`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
const found = await getCountFromServer(
|
|
498
|
+
query(entriesCollection(slug), where(key, "!=", null))
|
|
499
|
+
);
|
|
500
|
+
return found.data().count;
|
|
501
|
+
},
|
|
502
|
+
async purge(slug, key, options = {}) {
|
|
503
|
+
const user = currentUser();
|
|
504
|
+
if (!can(user, "manage-schema")) {
|
|
505
|
+
throw new PermissionError(
|
|
506
|
+
"manage-schema",
|
|
507
|
+
slug,
|
|
508
|
+
`Purging the values of field "${key}" requires the manage-schema permission.`
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
assertTopLevelKey(slug, key);
|
|
512
|
+
const type = resolveType(slug);
|
|
513
|
+
if (key in type.fields) {
|
|
514
|
+
throw new SchemaError(
|
|
515
|
+
`Field "${key}" of content type "${type.slug}" is still part of the schema. Remove it first; removing keeps the values, purging destroys them.`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
if (type.removedFields?.[key] === void 0) {
|
|
519
|
+
throw new SchemaError(
|
|
520
|
+
`Content type "${type.slug}" has no removed field "${key}" to purge.`
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const size = Math.min(
|
|
524
|
+
Math.max(Math.trunc(options.limit ?? MAX_FIELD_PURGE), 1),
|
|
525
|
+
MAX_FIELD_PURGE
|
|
526
|
+
);
|
|
527
|
+
const found = await getDocs(
|
|
528
|
+
query(entriesCollection(slug), where(key, "!=", null), limit(size + 1))
|
|
529
|
+
);
|
|
530
|
+
const targets = found.docs.slice(0, size);
|
|
531
|
+
if (targets.length === 0) return { purged: 0, remaining: false };
|
|
532
|
+
const batch = writeBatch(db);
|
|
533
|
+
for (const snapshot of targets) {
|
|
534
|
+
batch.update(snapshot.ref, {
|
|
535
|
+
[key]: deleteField(),
|
|
536
|
+
updatedAt: serverTimestamp(),
|
|
537
|
+
updatedBy: user.uid
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
await batch.commit();
|
|
541
|
+
return { purged: targets.length, remaining: found.docs.length > size };
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
const migrations = {
|
|
545
|
+
async countBehind(slug, targetVersion) {
|
|
546
|
+
assertCan(currentUser(), "manage-schema", { type: resolveType(slug) });
|
|
547
|
+
const found = await getCountFromServer(
|
|
548
|
+
query(entriesCollection(slug), where("schemaVersion", "<", targetVersion))
|
|
549
|
+
);
|
|
550
|
+
return found.data().count;
|
|
551
|
+
},
|
|
552
|
+
async listBehind(slug, targetVersion, migrationQuery = {}) {
|
|
553
|
+
assertCan(currentUser(), "manage-schema", { type: resolveType(slug) });
|
|
554
|
+
const size = normalizeLimit(migrationQuery.limit);
|
|
555
|
+
const found = await getDocs(
|
|
556
|
+
query(
|
|
557
|
+
entriesCollection(slug),
|
|
558
|
+
where("schemaVersion", "<", targetVersion),
|
|
559
|
+
orderBy("schemaVersion", "asc"),
|
|
560
|
+
orderBy("__name__", "asc"),
|
|
561
|
+
limit(size + 1)
|
|
562
|
+
)
|
|
563
|
+
);
|
|
564
|
+
const docs = found.docs.slice(0, size);
|
|
565
|
+
return {
|
|
566
|
+
items: docs.map((snapshot) => toStoredRecord(snapshot.id, snapshot.data())),
|
|
567
|
+
cursor: null,
|
|
568
|
+
hasMore: found.docs.length > size
|
|
569
|
+
};
|
|
570
|
+
},
|
|
571
|
+
async applyMigration(slug, entryId, values, write) {
|
|
572
|
+
const user = currentUser();
|
|
573
|
+
assertCan(user, "manage-schema", { type: resolveType(slug) });
|
|
574
|
+
refuse(checkWritePayload(values, { maxFields: MAX_ENTRY_FIELDS }));
|
|
575
|
+
const reference = doc(db, entryPath(slug, entryId));
|
|
576
|
+
return await runTransaction(db, async (transaction) => {
|
|
577
|
+
const snapshot = await transaction.get(reference);
|
|
578
|
+
if (!snapshot.exists()) return false;
|
|
579
|
+
const stored = snapshot.data();
|
|
580
|
+
const version = typeof stored["schemaVersion"] === "number" ? stored["schemaVersion"] : 1;
|
|
581
|
+
if (version !== write.expectedVersion) return false;
|
|
582
|
+
const dropped = {};
|
|
583
|
+
for (const key of Object.keys(stored)) {
|
|
584
|
+
if (ADAPTER_OWNED_KEYS.has(key)) continue;
|
|
585
|
+
if (!(key in values)) dropped[key] = deleteField();
|
|
586
|
+
}
|
|
587
|
+
transaction.update(reference, {
|
|
588
|
+
...values,
|
|
589
|
+
...dropped,
|
|
590
|
+
[MEDIA_REFS_KEY]: collectMediaIds(values),
|
|
591
|
+
schemaVersion: write.nextVersion,
|
|
592
|
+
updatedAt: serverTimestamp(),
|
|
593
|
+
updatedBy: user.uid
|
|
594
|
+
});
|
|
595
|
+
return true;
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
const settings = {
|
|
600
|
+
async get(key) {
|
|
601
|
+
const snapshot = await getDoc(doc(db, settingsPath(key)));
|
|
602
|
+
return snapshot.exists() ? snapshot.data() : null;
|
|
603
|
+
},
|
|
604
|
+
async set(key, value) {
|
|
605
|
+
assertCan(currentUser(), "manage-settings");
|
|
606
|
+
if (key === BOOTSTRAP_KEY) {
|
|
607
|
+
throw new SchemaError(
|
|
608
|
+
`"${BOOTSTRAP_KEY}" is the first-run marker, not a settings document. It is written once at setup and never again.`
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
guardWrite(value, MAX_SETTINGS_FIELDS);
|
|
612
|
+
await setDoc(
|
|
613
|
+
doc(db, settingsPath(key)),
|
|
614
|
+
{ ...value, updatedAt: serverTimestamp() },
|
|
615
|
+
{ merge: true }
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
return {
|
|
620
|
+
async list(slug, listQuery = {}) {
|
|
621
|
+
const type = resolveType(slug);
|
|
622
|
+
assertCanRead(type);
|
|
623
|
+
assertListable(type);
|
|
624
|
+
const size = normalizeLimit(listQuery.limit);
|
|
625
|
+
const constraints = constraintsFor(type, listQuery);
|
|
626
|
+
if (listQuery.cursor) {
|
|
627
|
+
const anchor = await snapshotFor(slug, listQuery.cursor);
|
|
628
|
+
if (anchor.exists()) constraints.push(startAfter(anchor));
|
|
629
|
+
}
|
|
630
|
+
constraints.push(limit(size + 1));
|
|
631
|
+
const found = await getDocs(
|
|
632
|
+
query(entriesCollection(slug), ...constraints)
|
|
633
|
+
);
|
|
634
|
+
const docs = found.docs.slice(0, size);
|
|
635
|
+
const hasMore = found.docs.length > size;
|
|
636
|
+
return {
|
|
637
|
+
items: docs.map((snapshot) => toEntry(type, snapshot)),
|
|
638
|
+
cursor: hasMore ? docs.at(-1)?.id ?? null : null,
|
|
639
|
+
hasMore
|
|
640
|
+
};
|
|
641
|
+
},
|
|
642
|
+
async get(slug, entryId) {
|
|
643
|
+
const type = resolveType(slug);
|
|
644
|
+
assertCanRead(type);
|
|
645
|
+
const snapshot = await snapshotFor(slug, entryId);
|
|
646
|
+
return snapshot.exists() ? entryFromStored(type, snapshot.id, snapshot.data(), fieldTypes) : null;
|
|
647
|
+
},
|
|
648
|
+
async getBySlug(slug, value, options = {}) {
|
|
649
|
+
const type = resolveType(slug);
|
|
650
|
+
assertCanRead(type);
|
|
651
|
+
const key = slugFieldKey(type);
|
|
652
|
+
if (key === null) {
|
|
653
|
+
throw new SchemaError(
|
|
654
|
+
`Content type "${slug}" has no slug field, so it cannot be looked up by slug.`
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
const status = options.status ?? "any";
|
|
658
|
+
const constraints = [where(key, "==", value)];
|
|
659
|
+
if (status !== "any") constraints.push(where("status", "==", status));
|
|
660
|
+
constraints.push(limit(1));
|
|
661
|
+
const found = await getDocs(query(entriesCollection(slug), ...constraints));
|
|
662
|
+
const first = found.docs[0];
|
|
663
|
+
return first ? toEntry(type, first) : null;
|
|
664
|
+
},
|
|
665
|
+
async getSingle(slug) {
|
|
666
|
+
const type = resolveType(slug);
|
|
667
|
+
assertCanRead(type);
|
|
668
|
+
if (!isSingleton(type)) {
|
|
669
|
+
throw new SchemaError(
|
|
670
|
+
`Content type "${slug}" is a collection. Read it with list() or get().`
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
const entryId = singletonEntryId(slug);
|
|
674
|
+
const snapshot = await snapshotFor(slug, entryId);
|
|
675
|
+
return snapshot.exists() ? entryFromStored(type, entryId, snapshot.data(), fieldTypes) : null;
|
|
676
|
+
},
|
|
677
|
+
async create(slug, input) {
|
|
678
|
+
const type = resolveType(slug);
|
|
679
|
+
if (isSingleton(type)) {
|
|
680
|
+
const entryId = singletonEntryId(slug);
|
|
681
|
+
const existing = await snapshotFor(slug, entryId);
|
|
682
|
+
assertSingletonCreatable(type, existing.exists());
|
|
683
|
+
return writeEntry(type, entryId, input, "create");
|
|
684
|
+
}
|
|
685
|
+
const reference = doc(entriesCollection(slug));
|
|
686
|
+
return writeEntry(type, reference.id, input, "create");
|
|
687
|
+
},
|
|
688
|
+
async update(slug, entryId, input) {
|
|
689
|
+
const type = resolveType(slug);
|
|
690
|
+
assertSingletonEntryId(type, entryId);
|
|
691
|
+
return writeEntry(type, entryId, input, "update");
|
|
692
|
+
},
|
|
693
|
+
async delete(slug, entryId) {
|
|
694
|
+
const type = resolveType(slug);
|
|
695
|
+
assertDeletable(type);
|
|
696
|
+
const user = currentUser();
|
|
697
|
+
assertNotDenied(user, "delete", type);
|
|
698
|
+
const reference = doc(db, entryPath(slug, entryId));
|
|
699
|
+
const existing = await getDoc(reference);
|
|
700
|
+
assertCan(user, "delete", {
|
|
701
|
+
type,
|
|
702
|
+
...existing.exists() ? { entry: { createdBy: metaFromStored(entryId, existing.data()).createdBy } } : {}
|
|
703
|
+
});
|
|
704
|
+
await deleteDoc(reference);
|
|
705
|
+
},
|
|
706
|
+
async count(slug, countQuery = {}) {
|
|
707
|
+
const type = resolveType(slug);
|
|
708
|
+
assertCanRead(type);
|
|
709
|
+
const constraints = [];
|
|
710
|
+
const status = countQuery.status ?? "any";
|
|
711
|
+
if (status !== "any") constraints.push(where("status", "==", status));
|
|
712
|
+
for (const filter of countQuery.filters ?? []) {
|
|
713
|
+
constraints.push(where(filter.field, filter.op, filter.value));
|
|
714
|
+
}
|
|
715
|
+
const range = searchRange(type, countQuery.search);
|
|
716
|
+
if (range !== null) {
|
|
717
|
+
constraints.push(where(range.field, ">=", range.term));
|
|
718
|
+
constraints.push(where(range.field, "<=", `${range.term}${PREFIX_END}`));
|
|
719
|
+
}
|
|
720
|
+
const snapshot = await getCountFromServer(
|
|
721
|
+
query(entriesCollection(slug), ...constraints)
|
|
722
|
+
);
|
|
723
|
+
return snapshot.data().count;
|
|
724
|
+
},
|
|
725
|
+
media,
|
|
726
|
+
schemas,
|
|
727
|
+
fieldData,
|
|
728
|
+
migrations,
|
|
729
|
+
settings
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
function sameMatrix(stored, next) {
|
|
733
|
+
const current = typeof stored === "object" && stored !== null && !Array.isArray(stored) ? stored : {};
|
|
734
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(current), ...Object.keys(next)]);
|
|
735
|
+
for (const key of keys) {
|
|
736
|
+
const left = current[key];
|
|
737
|
+
const right = next[key];
|
|
738
|
+
if (!Array.isArray(left) || right === void 0) return false;
|
|
739
|
+
if (left.length !== right.length) return false;
|
|
740
|
+
if (!right.every((role) => left.includes(role))) return false;
|
|
741
|
+
}
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
var ADAPTER_OWNED_KEYS = /* @__PURE__ */ new Set([
|
|
745
|
+
"status",
|
|
746
|
+
"schemaVersion",
|
|
747
|
+
"createdAt",
|
|
748
|
+
"createdBy",
|
|
749
|
+
"updatedAt",
|
|
750
|
+
"updatedBy",
|
|
751
|
+
"publishedAt",
|
|
752
|
+
MEDIA_REFS_KEY
|
|
753
|
+
]);
|
|
754
|
+
function toStoredRecord(entryId, data) {
|
|
755
|
+
const values = {};
|
|
756
|
+
for (const [key, value] of Object.entries(data)) {
|
|
757
|
+
if (!ADAPTER_OWNED_KEYS.has(key)) values[key] = value;
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
id: entryId,
|
|
761
|
+
schemaVersion: typeof data["schemaVersion"] === "number" ? data["schemaVersion"] : 1,
|
|
762
|
+
values
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
function mediaFromStored(mediaId, data) {
|
|
766
|
+
return {
|
|
767
|
+
id: mediaId,
|
|
768
|
+
name: typeof data["name"] === "string" ? data["name"] : "",
|
|
769
|
+
type: typeof data["type"] === "string" ? data["type"] : "application/octet-stream",
|
|
770
|
+
size: typeof data["size"] === "number" ? data["size"] : 0,
|
|
771
|
+
url: typeof data["url"] === "string" ? data["url"] : "",
|
|
772
|
+
path: typeof data["path"] === "string" ? data["path"] : "",
|
|
773
|
+
alt: typeof data["alt"] === "string" ? data["alt"] : null,
|
|
774
|
+
width: typeof data["width"] === "number" ? data["width"] : null,
|
|
775
|
+
height: typeof data["height"] === "number" ? data["height"] : null,
|
|
776
|
+
createdAt: toIso(data["createdAt"]) ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
777
|
+
createdBy: typeof data["createdBy"] === "string" ? data["createdBy"] : ""
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
var MediaUploadError = class extends Error {
|
|
781
|
+
name = "MediaUploadError";
|
|
782
|
+
code;
|
|
783
|
+
/** Set when the file itself was refused, so the UI can point at the offending rule. */
|
|
784
|
+
rejection;
|
|
785
|
+
/**
|
|
786
|
+
* The Storage object that outlived the failed upload, when cleanup could not remove it.
|
|
787
|
+
* Null means nothing was left behind, which is the ordinary case.
|
|
788
|
+
*/
|
|
789
|
+
orphanedPath;
|
|
790
|
+
constructor(code, message, detail = {}) {
|
|
791
|
+
super(message, detail.cause === void 0 ? void 0 : { cause: detail.cause });
|
|
792
|
+
this.code = code;
|
|
793
|
+
this.rejection = detail.rejection ?? null;
|
|
794
|
+
this.orphanedPath = detail.orphanedPath ?? null;
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
function createMediaUpload(deps, file, options = {}) {
|
|
798
|
+
return prepare(deps, file, options, null);
|
|
799
|
+
}
|
|
800
|
+
function createMediaReplace(deps, existing, file, options = {}) {
|
|
801
|
+
return prepare(deps, file, options, existing);
|
|
802
|
+
}
|
|
803
|
+
function prepare(deps, file, options, existing) {
|
|
804
|
+
const readImageSize = deps.readImageSize ?? browserImageSize;
|
|
805
|
+
const mediaId = existing?.id ?? deps.media.reserveId();
|
|
806
|
+
const fileName = existing === null ? safeFileName(file.name, file.type) : lastSegment(existing.path);
|
|
807
|
+
const path = existing?.path ?? storageObjectPath(mediaId, fileName);
|
|
808
|
+
let attempt = 0;
|
|
809
|
+
let task = null;
|
|
810
|
+
let cancelled = false;
|
|
811
|
+
let state = {
|
|
812
|
+
phase: "idle",
|
|
813
|
+
bytesTransferred: 0,
|
|
814
|
+
totalBytes: file.size,
|
|
815
|
+
fraction: 0,
|
|
816
|
+
attempt: 0
|
|
817
|
+
};
|
|
818
|
+
function report(next) {
|
|
819
|
+
state = { ...state, ...next };
|
|
820
|
+
options.onProgress?.(state);
|
|
821
|
+
}
|
|
822
|
+
async function run() {
|
|
823
|
+
const rejection = checkMediaFile({
|
|
824
|
+
name: file.name,
|
|
825
|
+
type: file.type,
|
|
826
|
+
size: file.size
|
|
827
|
+
});
|
|
828
|
+
if (rejection !== null) {
|
|
829
|
+
report({ phase: "failed", attempt: attempt + 1 });
|
|
830
|
+
throw new MediaUploadError("rejected", rejection.message, { rejection });
|
|
831
|
+
}
|
|
832
|
+
if (existing !== null && normalizeMediaType(file.type) !== existing.type) {
|
|
833
|
+
const message = `A ${existing.type} file can only be replaced by another ${existing.type} file. Upload ${file.type || "this file"} as a new item instead.`;
|
|
834
|
+
report({ phase: "failed", attempt: attempt + 1 });
|
|
835
|
+
throw new MediaUploadError("rejected", message, {
|
|
836
|
+
rejection: { code: "type-not-allowed", message }
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
attempt += 1;
|
|
840
|
+
cancelled = false;
|
|
841
|
+
report({
|
|
842
|
+
phase: "uploading",
|
|
843
|
+
bytesTransferred: 0,
|
|
844
|
+
fraction: 0,
|
|
845
|
+
attempt,
|
|
846
|
+
totalBytes: file.size
|
|
847
|
+
});
|
|
848
|
+
const uploadTask = uploadBytesResumable(ref(deps.storage, path), file, {
|
|
849
|
+
contentType: file.type,
|
|
850
|
+
// The bucket has no idea who wrote an object unless it is told. The rules read this
|
|
851
|
+
// back on update and delete, which is what lets the uploader clean up after
|
|
852
|
+
// themselves without granting an author delete rights over everybody's files.
|
|
853
|
+
customMetadata: { uploadedBy: deps.currentUser().uid, mediaId }
|
|
854
|
+
});
|
|
855
|
+
task = uploadTask;
|
|
856
|
+
uploadTask.on("state_changed", (snapshot) => {
|
|
857
|
+
const total = snapshot.totalBytes === 0 ? file.size : snapshot.totalBytes;
|
|
858
|
+
report({
|
|
859
|
+
phase: "uploading",
|
|
860
|
+
bytesTransferred: snapshot.bytesTransferred,
|
|
861
|
+
totalBytes: total,
|
|
862
|
+
fraction: total === 0 ? 0 : snapshot.bytesTransferred / total
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
try {
|
|
866
|
+
await uploadTask;
|
|
867
|
+
} catch (thrown) {
|
|
868
|
+
task = null;
|
|
869
|
+
const code = thrown.code ?? "";
|
|
870
|
+
if (cancelled || code === "storage/canceled") {
|
|
871
|
+
report({ phase: "cancelled" });
|
|
872
|
+
throw new MediaUploadError("cancelled", "The upload was cancelled.", {
|
|
873
|
+
cause: thrown
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
report({ phase: "failed" });
|
|
877
|
+
throw new MediaUploadError(
|
|
878
|
+
"storage-failed",
|
|
879
|
+
"The file could not be uploaded. Nothing was stored, so retrying is safe.",
|
|
880
|
+
{ cause: thrown }
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
task = null;
|
|
884
|
+
report({
|
|
885
|
+
phase: "storing",
|
|
886
|
+
bytesTransferred: file.size,
|
|
887
|
+
totalBytes: file.size,
|
|
888
|
+
fraction: 1
|
|
889
|
+
});
|
|
890
|
+
const url = await getDownloadURL(uploadTask.snapshot.ref);
|
|
891
|
+
const size = await imageSize(file, readImageSize);
|
|
892
|
+
try {
|
|
893
|
+
const item = existing === null ? await deps.media.create(
|
|
894
|
+
{
|
|
895
|
+
name: file.name,
|
|
896
|
+
type: file.type,
|
|
897
|
+
size: file.size,
|
|
898
|
+
url,
|
|
899
|
+
path,
|
|
900
|
+
alt: options.alt ?? null,
|
|
901
|
+
width: size?.width ?? null,
|
|
902
|
+
height: size?.height ?? null
|
|
903
|
+
},
|
|
904
|
+
mediaId
|
|
905
|
+
) : (
|
|
906
|
+
// Alt text is not in the patch. It describes what the picture shows and it is
|
|
907
|
+
// the editor's sentence, not the file's, so a replace keeps it and the library
|
|
908
|
+
// says so, rather than discarding a description nobody asked it to discard.
|
|
909
|
+
await deps.media.update(mediaId, {
|
|
910
|
+
name: file.name,
|
|
911
|
+
size: file.size,
|
|
912
|
+
url,
|
|
913
|
+
width: size?.width ?? null,
|
|
914
|
+
height: size?.height ?? null
|
|
915
|
+
})
|
|
916
|
+
);
|
|
917
|
+
report({ phase: "done" });
|
|
918
|
+
return item;
|
|
919
|
+
} catch (thrown) {
|
|
920
|
+
if (existing !== null) {
|
|
921
|
+
report({ phase: "failed" });
|
|
922
|
+
throw new MediaUploadError(
|
|
923
|
+
"metadata-failed",
|
|
924
|
+
`The new file was stored but the library record still describes the old one, and the old link no longer works. Reload the library and replace "${existing.name}" again.`,
|
|
925
|
+
{ cause: thrown }
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
const removed = await removeOrphan(deps.storage, path);
|
|
929
|
+
report({ phase: "failed" });
|
|
930
|
+
throw new MediaUploadError(
|
|
931
|
+
"metadata-failed",
|
|
932
|
+
removed ? "The file was uploaded but could not be recorded, so the upload was undone. Try again." : `The file was uploaded but could not be recorded, and the uploaded copy at ${path} could not be removed. It is not in the library and has to be cleaned up.`,
|
|
933
|
+
removed ? { cause: thrown } : { cause: thrown, orphanedPath: path }
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return {
|
|
938
|
+
mediaId,
|
|
939
|
+
path,
|
|
940
|
+
fileName,
|
|
941
|
+
progress: () => state,
|
|
942
|
+
start: run,
|
|
943
|
+
retry: run,
|
|
944
|
+
cancel() {
|
|
945
|
+
cancelled = true;
|
|
946
|
+
task?.cancel();
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
function lastSegment(path) {
|
|
951
|
+
return path.slice(path.lastIndexOf("/") + 1);
|
|
952
|
+
}
|
|
953
|
+
function uploadMedia(deps, file, options = {}) {
|
|
954
|
+
return createMediaUpload(deps, file, options).start();
|
|
955
|
+
}
|
|
956
|
+
async function removeOrphan(storage, path) {
|
|
957
|
+
try {
|
|
958
|
+
await deleteObject(ref(storage, path));
|
|
959
|
+
return true;
|
|
960
|
+
} catch (thrown) {
|
|
961
|
+
return thrown.code === "storage/object-not-found";
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
async function imageSize(file, reader) {
|
|
965
|
+
if (mediaTypeRule(file.type)?.category !== "image") return null;
|
|
966
|
+
try {
|
|
967
|
+
return await reader(file, file.type);
|
|
968
|
+
} catch {
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
var browserImageSize = async (file) => {
|
|
973
|
+
const decode = globalThis.createImageBitmap;
|
|
974
|
+
if (decode === void 0) return null;
|
|
975
|
+
const bitmap = await decode(file);
|
|
976
|
+
try {
|
|
977
|
+
return { width: bitmap.width, height: bitmap.height };
|
|
978
|
+
} finally {
|
|
979
|
+
bitmap.close();
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
function createMediaUploader(deps) {
|
|
983
|
+
return {
|
|
984
|
+
create: (file, options) => createMediaUpload(deps, file, options),
|
|
985
|
+
replace: (existing, file, options) => createMediaReplace(deps, existing, file, options)
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
export { MediaUploadError, browserImageSize, createFirestoreAdapter, createMediaReplace, createMediaUpload, createMediaUploader, removeOrphan, uploadMedia };
|
|
990
|
+
//# sourceMappingURL=index.js.map
|
|
991
|
+
//# sourceMappingURL=index.js.map
|