@stoker-platform/cli 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE.md +102 -0
  2. package/init-files/.##gitignore## +102 -0
  3. package/init-files/.devcontainer/devcontainer.json +14 -0
  4. package/init-files/.env/.env +70 -0
  5. package/init-files/.eslintrc.cjs +35 -0
  6. package/init-files/.firebaserc +6 -0
  7. package/init-files/.prettierignore +86 -0
  8. package/init-files/.prettierrc +5 -0
  9. package/init-files/bin/build.js +221 -0
  10. package/init-files/bin/shim.js +159 -0
  11. package/init-files/extensions/firestore-send-email.env +7 -0
  12. package/init-files/external.package.json +4 -0
  13. package/init-files/firebase-rules/database.rules.json +9 -0
  14. package/init-files/firebase-rules/firestore.custom.rules +19 -0
  15. package/init-files/firebase-rules/firestore.indexes.json +0 -0
  16. package/init-files/firebase-rules/firestore.rules +0 -0
  17. package/init-files/firebase-rules/storage.rules +0 -0
  18. package/init-files/firebase.hosting.json +122 -0
  19. package/init-files/firebase.json +52 -0
  20. package/init-files/functions/.##gitignore## +14 -0
  21. package/init-files/functions/.eslintrc.cjs +28 -0
  22. package/init-files/functions/package.json +46 -0
  23. package/init-files/functions/prompts/chat.prompt +17 -0
  24. package/init-files/functions/src/index.ts +457 -0
  25. package/init-files/functions/tsconfig.dev.json +3 -0
  26. package/init-files/functions/tsconfig.json +17 -0
  27. package/init-files/icons/logo-large.png +0 -0
  28. package/init-files/icons/logo-small.png +0 -0
  29. package/init-files/ops.js +25 -0
  30. package/init-files/package.json +53 -0
  31. package/init-files/project-data.json +5 -0
  32. package/init-files/remoteconfig.template.json +1 -0
  33. package/init-files/src/collections/Inbox.ts +444 -0
  34. package/init-files/src/collections/Outbox.ts +270 -0
  35. package/init-files/src/collections/Settings.ts +44 -0
  36. package/init-files/src/collections/Users.ts +138 -0
  37. package/init-files/src/main.ts +245 -0
  38. package/init-files/src/utils.ts +3 -0
  39. package/init-files/src/vite-env.d.ts +1 -0
  40. package/init-files/test/test.ts +5 -0
  41. package/init-files/tsconfig.json +23 -0
  42. package/init-files/vitest.config.ts +9 -0
  43. package/lib/package.json +45 -0
  44. package/lib/src/data/exportToBigQuery.js +41 -0
  45. package/lib/src/data/seedData.js +347 -0
  46. package/lib/src/deploy/applySchema.js +43 -0
  47. package/lib/src/deploy/cloud-functions/getFunctionsData.js +18 -0
  48. package/lib/src/deploy/deployProject.js +116 -0
  49. package/lib/src/deploy/firestore-export/exportFirestoreData.js +29 -0
  50. package/lib/src/deploy/firestore-ttl/deployTTLs.js +127 -0
  51. package/lib/src/deploy/live-update/liveUpdate.js +22 -0
  52. package/lib/src/deploy/maintenance/activateMaintenanceMode.js +9 -0
  53. package/lib/src/deploy/maintenance/disableMaintenanceMode.js +9 -0
  54. package/lib/src/deploy/maintenance/setDeploymentStatus.js +22 -0
  55. package/lib/src/deploy/rules-indexes/generateFirestoreIndexes.js +23 -0
  56. package/lib/src/deploy/rules-indexes/generateFirestoreRules.js +35 -0
  57. package/lib/src/deploy/rules-indexes/generateStorageRules.js +23 -0
  58. package/lib/src/deploy/schema/generateSchema.js +184 -0
  59. package/lib/src/deploy/schema/persistSchema.js +14 -0
  60. package/lib/src/lint/lintSchema.js +1491 -0
  61. package/lib/src/lint/securityReport.js +223 -0
  62. package/lib/src/main.js +460 -0
  63. package/lib/src/migration/firestore/migrateFirestore.js +8 -0
  64. package/lib/src/migration/firestore/operations/deleteField.js +58 -0
  65. package/lib/src/migration/migrateAll.js +30 -0
  66. package/lib/src/ops/auditDenormalized.js +124 -0
  67. package/lib/src/ops/auditPermissions.js +92 -0
  68. package/lib/src/ops/auditRelations.js +186 -0
  69. package/lib/src/ops/explainPreloadQueries.js +65 -0
  70. package/lib/src/ops/getUser.js +10 -0
  71. package/lib/src/ops/getUserPermissions.js +19 -0
  72. package/lib/src/ops/getUserRecord.js +20 -0
  73. package/lib/src/ops/listProjects.js +8 -0
  74. package/lib/src/ops/setUserCollection.js +14 -0
  75. package/lib/src/ops/setUserDocument.js +11 -0
  76. package/lib/src/ops/setUserRole.js +14 -0
  77. package/lib/src/project/addProject.js +935 -0
  78. package/lib/src/project/addRecord.js +9 -0
  79. package/lib/src/project/addRecordPrompt.js +205 -0
  80. package/lib/src/project/addTenant.js +59 -0
  81. package/lib/src/project/buildWebApp.js +10 -0
  82. package/lib/src/project/customDomain.js +157 -0
  83. package/lib/src/project/deleteProject.js +51 -0
  84. package/lib/src/project/deleteRecord.js +11 -0
  85. package/lib/src/project/deleteTenant.js +49 -0
  86. package/lib/src/project/getOne.js +25 -0
  87. package/lib/src/project/getSome.js +28 -0
  88. package/lib/src/project/initProject.js +16 -0
  89. package/lib/src/project/prepareEmulatorData.js +125 -0
  90. package/lib/src/project/setProject.js +13 -0
  91. package/lib/src/project/startEmulators.js +30 -0
  92. package/lib/src/project/updateRecord.js +9 -0
  93. package/lib/tsconfig.tsbuildinfo +1 -0
  94. package/package.json +45 -0
@@ -0,0 +1,1491 @@
1
+ import { tryPromise, getCustomization, getField, getInverseRelationType, isDependencyField, isRelationField, systemFields, isIncludedField, getSystemFieldsSchema, getAccessFields, getDependencyIndexFields, isPaginationEnabled, roleHasOperationAccess, tryFunction, getFieldCustomization, } from "@stoker-platform/utils";
2
+ import { generateSchema } from "../deploy/schema/generateSchema.js";
3
+ import { getCustomizationFiles } from "@stoker-platform/node-client";
4
+ import { join } from "path";
5
+ import { pathToFileURL } from "url";
6
+ export const lintSchema = async (noLog = false) => {
7
+ const path = join(process.cwd(), "lib", "main.js");
8
+ const url = pathToFileURL(path).href;
9
+ const globalConfigFile = await import(url);
10
+ const globalConfig = globalConfigFile.default("node");
11
+ const schema = await generateSchema(true);
12
+ const customizationFiles = await getCustomizationFiles(join(process.cwd(), "lib", "collections"), Object.keys(schema.collections));
13
+ const customizationModules = getCustomization(Object.keys(schema.collections), customizationFiles, "node");
14
+ const warnings = [];
15
+ const errors = [];
16
+ const roles = schema.config.roles;
17
+ const collectionNames = Object.keys(schema.collections);
18
+ const collectionSchemas = Object.entries(schema.collections);
19
+ const systemFieldSchema = getSystemFieldsSchema();
20
+ const collectionNamesSet = new Set();
21
+ let authCollectionFound = false;
22
+ if (globalConfig.disabledCollections) {
23
+ for (const collectionName of globalConfig.disabledCollections) {
24
+ if (!collectionNames.includes(collectionName)) {
25
+ errors.push(`Disabled collection ${collectionName} does not exist`);
26
+ }
27
+ }
28
+ }
29
+ const enableMultiFactorAuth = globalConfig.auth.enableMultiFactorAuth;
30
+ if (typeof enableMultiFactorAuth === "object") {
31
+ enableMultiFactorAuth.forEach((role) => {
32
+ if (!roles.includes(role)) {
33
+ errors.push(`Multi-factor auth role ${role} does not exist`);
34
+ }
35
+ });
36
+ }
37
+ const writeLogIndexExemption = globalConfig.firebase?.writeLogIndexExemption;
38
+ if (writeLogIndexExemption) {
39
+ for (const fieldName of writeLogIndexExemption) {
40
+ if (!systemFields.includes(fieldName)) {
41
+ errors.push(`Invalid write log index exemption field: ${fieldName}. Must be a valid system field.`);
42
+ }
43
+ }
44
+ }
45
+ const preload = globalConfig.preload;
46
+ const async = await tryPromise(preload?.async);
47
+ const sync = await tryPromise(preload?.sync);
48
+ if (sync) {
49
+ for (const collectionName of sync) {
50
+ if (!collectionNames.includes(collectionName)) {
51
+ errors.push(`Preload sync collection ${collectionName} does not exist`);
52
+ }
53
+ }
54
+ }
55
+ if (async) {
56
+ for (const collectionName of async) {
57
+ if (!collectionNames.includes(collectionName)) {
58
+ errors.push(`Preload async collection ${collectionName} does not exist`);
59
+ }
60
+ }
61
+ }
62
+ const adminAccess = tryFunction(globalConfig.admin?.access);
63
+ if (adminAccess) {
64
+ for (const adminAccessRole of adminAccess) {
65
+ if (!roles.includes(adminAccessRole)) {
66
+ errors.push(`Admin app access has invalid role value ${adminAccessRole}`);
67
+ }
68
+ }
69
+ }
70
+ const dashboard = await tryPromise(globalConfig.admin?.dashboard);
71
+ if (dashboard) {
72
+ for (const dashboardItem of dashboard) {
73
+ const collectionSchema = schema.collections[dashboardItem.collection];
74
+ if (!collectionNames.includes(dashboardItem.collection)) {
75
+ errors.push(`Dashboard has invalid collection value ${dashboardItem.collection}`);
76
+ }
77
+ if (dashboardItem.roles) {
78
+ for (const role of dashboardItem.roles) {
79
+ if (!roles.includes(role)) {
80
+ errors.push(`Dashboard has invalid role value ${role}`);
81
+ }
82
+ }
83
+ }
84
+ if (dashboardItem.kind === "metric") {
85
+ if (dashboardItem.type !== "count" &&
86
+ !collectionSchema.fields.map((field) => field.name).includes(dashboardItem.field)) {
87
+ errors.push(`Dashboard has invalid field value ${dashboardItem.field}`);
88
+ }
89
+ }
90
+ else if (dashboardItem.kind === "chart") {
91
+ if (!collectionSchema.fields
92
+ .concat(systemFieldSchema)
93
+ .map((field) => field.name)
94
+ .includes(dashboardItem.dateField)) {
95
+ errors.push(`Dashboard has invalid date field value ${dashboardItem.dateField}`);
96
+ }
97
+ if (dashboardItem.metricField1 &&
98
+ !collectionSchema.fields.map((field) => field.name).includes(dashboardItem.metricField1)) {
99
+ errors.push(`Dashboard has invalid metric field value ${dashboardItem.metricField1}`);
100
+ }
101
+ if (dashboardItem.metricField2 &&
102
+ !collectionSchema.fields.map((field) => field.name).includes(dashboardItem.metricField2)) {
103
+ errors.push(`Dashboard has invalid metric field value ${dashboardItem.metricField2}`);
104
+ }
105
+ }
106
+ else if (dashboardItem.kind === "reminder") {
107
+ for (const column of dashboardItem.columns) {
108
+ if (!collectionSchema.fields.map((field) => field.name).includes(column)) {
109
+ errors.push(`Dashboard has invalid column value ${column}`);
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ const homePage = await tryPromise(globalConfig.admin?.homePage);
116
+ if (homePage) {
117
+ for (const role of Object.keys(homePage)) {
118
+ if (!roles.includes(role)) {
119
+ errors.push(`Home page configuration has invalid role value ${role}`);
120
+ }
121
+ // eslint-disable-next-line security/detect-object-injection
122
+ if (!collectionNames.includes(homePage[role])) {
123
+ // eslint-disable-next-line security/detect-object-injection
124
+ errors.push(`Home page configuration has invalid collection value ${homePage[role]} for role ${role}`);
125
+ }
126
+ }
127
+ }
128
+ for (const [collectionName, collectionSchema] of collectionSchemas) {
129
+ const { auth, fields, access, ttl, parentCollection, recordTitleField, softDelete, roleSystemFields, preloadCache, relationLists, fullTextSearch, ai, } = collectionSchema;
130
+ // eslint-disable-next-line security/detect-object-injection
131
+ const customization = customizationModules[collectionName];
132
+ const readRoles = roles.filter((role) => roleHasOperationAccess(collectionSchema, role, "read"));
133
+ const fieldNames = fields.map((field) => field.name);
134
+ const regex = /^(?!\/)(?!.*\/)(?!\.$)(?!\.\.$)(?!__.*__)[^/\s]{1,1500}$/;
135
+ if (!regex.test(collectionName)) {
136
+ errors.push(`Invalid collection name: ${collectionName}. Must be a valid Firestore collection ID.`);
137
+ }
138
+ if (collectionName.includes("?")) {
139
+ errors.push(`Invalid collection name: ${collectionName}. Collection names cannot contain question marks.`);
140
+ }
141
+ const formattedCollectionName = collectionName.replace(/\s+/g, "").replace(/^\w/, (c) => c.toUpperCase());
142
+ if (formattedCollectionName !== collectionName) {
143
+ errors.push(`Invalid collection name: ${collectionName}. Collection names should not have spaces and should start with a capital letter.`);
144
+ }
145
+ if (collectionName.includes("-")) {
146
+ errors.push(`Invalid collection name: ${collectionName}. Collection names cannot have dashes. Use underscores instead.`);
147
+ }
148
+ fieldNames.forEach((fieldName) => {
149
+ const formattedFieldName = fieldName.replace(/\s+/g, "").replace(/^\w/, (c) => c.toUpperCase());
150
+ if (formattedFieldName !== fieldName) {
151
+ errors.push(`Collection ${collectionName} has invalid field name: ${fieldName}. Field names should not have spaces and should start with a capital letter.`);
152
+ }
153
+ if (fieldName.includes(".")) {
154
+ errors.push(`Collection ${collectionName} has invalid field name: ${fieldName}. Field names cannot contain periods.`);
155
+ }
156
+ if (fieldName.endsWith("_Array") || fieldName.endsWith("_Single") || fieldName.endsWith("_Lowercase")) {
157
+ errors.push(`Collection ${collectionName} has invalid field name: ${fieldName}. Field names cannot end with _Array, _Single, or _Lowercase.`);
158
+ }
159
+ });
160
+ if (collectionNamesSet.has(collectionName)) {
161
+ errors.push(`Duplicate collection name: ${collectionName}`);
162
+ }
163
+ else {
164
+ collectionNamesSet.add(collectionName);
165
+ }
166
+ const fieldNamesSet = new Set();
167
+ fields.forEach((field) => {
168
+ if (fieldNamesSet.has(field.name)) {
169
+ errors.push(`Collection ${collectionName} has a duplicate field name: ${field.name}`);
170
+ }
171
+ else {
172
+ fieldNamesSet.add(field.name);
173
+ }
174
+ });
175
+ systemFields.forEach((field) => {
176
+ if (fieldNames.includes(field)) {
177
+ errors.push(`Collection ${collectionName} has a field with a reserved system field name: ${field}`);
178
+ }
179
+ });
180
+ if (ttl) {
181
+ const ttlField = getField(fields, ttl);
182
+ if (!ttlField) {
183
+ errors.push(`Collection ${collectionName} has a ttl field ${ttl} that does not exist`);
184
+ }
185
+ else if (!ttlField.required) {
186
+ errors.push(`Collection ${collectionName} has a ttl field ${ttl} that is not required`);
187
+ }
188
+ }
189
+ if (recordTitleField) {
190
+ const recordTitleFieldSchema = getField(fields, recordTitleField);
191
+ if (!recordTitleFieldSchema) {
192
+ errors.push(`Collection ${collectionName} has a title field ${recordTitleField} that does not exist`);
193
+ }
194
+ else {
195
+ if (recordTitleFieldSchema.type !== "String") {
196
+ errors.push(`Collection ${collectionName} record title field ${recordTitleField} must be a string`);
197
+ }
198
+ if (recordTitleFieldSchema.access) {
199
+ errors.push(`Collection ${collectionName} has a title field ${recordTitleField} with access restrictions`);
200
+ }
201
+ if (!recordTitleFieldSchema.required) {
202
+ errors.push(`Collection ${collectionName} has a title field ${recordTitleField} that is not a required field`);
203
+ }
204
+ }
205
+ }
206
+ if (auth) {
207
+ authCollectionFound = true;
208
+ const nameField = fields.find((field) => field.name === "Name");
209
+ if (!nameField || nameField.type !== "String" || !nameField.required) {
210
+ errors.push(`Auth collection ${collectionName} must have a required string field named "Name"`);
211
+ }
212
+ const userIdField = fields.find((field) => field.name === "User_ID");
213
+ if (!userIdField || userIdField.type !== "String") {
214
+ errors.push(`Auth collection ${collectionName} must have a string field named "User_ID"`);
215
+ }
216
+ const enabledField = fields.find((field) => field.name === "Enabled");
217
+ if (!enabledField || enabledField.type !== "Boolean" || !enabledField.required) {
218
+ errors.push(`Auth collection ${collectionName} must have a required boolean field named "Enabled"`);
219
+ }
220
+ const roleField = fields.find((field) => field.name === "Role");
221
+ if (!roleField || roleField.type !== "String" || !roleField.required || !roleField.values) {
222
+ errors.push(`Auth collection ${collectionName} must have a required string field named "Role" with a values property`);
223
+ }
224
+ else {
225
+ roleField.values.forEach((role) => {
226
+ if (!roles.includes(role)) {
227
+ errors.push(`Auth collection ${collectionName} has a Role field with invalid role value ${role}`);
228
+ }
229
+ });
230
+ }
231
+ const emailField = fields.find((field) => field.name === "Email");
232
+ if (!emailField ||
233
+ emailField.type !== "String" ||
234
+ !emailField.email ||
235
+ !emailField.unique ||
236
+ !emailField.required) {
237
+ errors.push(`Auth collection ${collectionName} must have a required, unique string field named "Email"`);
238
+ }
239
+ if (parentCollection) {
240
+ errors.push(`Auth collection ${collectionName} cannot have a parent collection`);
241
+ }
242
+ }
243
+ if (parentCollection) {
244
+ if (!collectionNames.includes(parentCollection)) {
245
+ errors.push(`Collection ${collectionName} has a parent collection ${parentCollection} that does not exist`);
246
+ }
247
+ }
248
+ if (roleSystemFields) {
249
+ for (const field of roleSystemFields) {
250
+ const systemField = field.field;
251
+ if (!systemFields.includes(systemField)) {
252
+ errors.push(`Collection ${collectionName} has an role system field assignment ${systemField} that does not exist`);
253
+ }
254
+ if (field.roles) {
255
+ for (const role of field.roles) {
256
+ if (!roles.includes(role)) {
257
+ errors.push(`Collection ${collectionName} has an role system field assignment ${systemField} with role ${role} that does not exist`);
258
+ }
259
+ }
260
+ }
261
+ }
262
+ }
263
+ if (relationLists) {
264
+ for (const relation of relationLists) {
265
+ const relationCollection = schema.collections[relation.collection];
266
+ if (!relationCollection) {
267
+ errors.push(`Collection ${collectionName} has a relation list collection ${relation.collection} that does not exist`);
268
+ }
269
+ else {
270
+ const relationField = getField(relationCollection.fields, relation.field);
271
+ if (!relationCollection) {
272
+ errors.push(`Collection ${collectionName} has a relation list collection ${relation.field} for collection ${relation.collection} that does not exist`);
273
+ }
274
+ else {
275
+ if (!relationField) {
276
+ errors.push(`Collection ${collectionName} has a relation list field ${relation.field} that does not exist in collection ${relation.collection}`);
277
+ }
278
+ else {
279
+ if (relation.roles) {
280
+ for (const role of relation.roles) {
281
+ if (!roles.includes(role)) {
282
+ errors.push(`Collection ${collectionName} has a relation list field ${relation.field} for collection ${relation.collection} with role ${role} that does not exist`);
283
+ }
284
+ if (relationField.access && !relationField.access.includes(role)) {
285
+ errors.push(`Collection ${collectionName} has a relation list field ${relation.field} for collection ${relation.collection} with role ${role} that does not have access to the field`);
286
+ }
287
+ }
288
+ }
289
+ else {
290
+ if (relationField.access) {
291
+ errors.push(`Collection ${collectionName} has a relation list field ${relation.field} for collection ${relation.collection} with access restrictions`);
292
+ }
293
+ }
294
+ }
295
+ }
296
+ }
297
+ }
298
+ }
299
+ if (preloadCache) {
300
+ for (const role of preloadCache.roles) {
301
+ if (!roles.includes(role)) {
302
+ errors.push(`Collection ${collectionName} has a preload cache role ${role} that does not exist`);
303
+ }
304
+ }
305
+ }
306
+ if (preloadCache?.range) {
307
+ if (preloadCache.range.fields.length > 3) {
308
+ errors.push(`Collection ${collectionName} cannot have more than three preload cache range fields`);
309
+ }
310
+ if (preloadCache.range.fields.length > 0) {
311
+ const rangeField = getField(fields.concat(systemFieldSchema), preloadCache.range.fields[0]);
312
+ if (!rangeField) {
313
+ errors.push(`Collection ${collectionName} has a preload cache with a range field ${preloadCache.range.fields[0]} that does not exist`);
314
+ }
315
+ else if (!rangeField.required) {
316
+ errors.push(`The first preload cache field for collection ${collectionName} must be a required field`);
317
+ }
318
+ }
319
+ preloadCache.range.fields.forEach((field) => {
320
+ const rangeField = getField(fields.concat(systemFieldSchema), field);
321
+ if (!rangeField) {
322
+ errors.push(`Collection ${collectionName} has a preload cache with a range field ${field} that does not exist`);
323
+ }
324
+ else if (rangeField.access) {
325
+ errors.push(`Collection ${collectionName} has a preload cache range field ${field} with access restrictions`);
326
+ }
327
+ });
328
+ }
329
+ if (softDelete) {
330
+ const softDeleteField = fields.find((field) => field.name === softDelete.archivedField);
331
+ const softDeleteTimestampField = fields.find((field) => field.name === softDelete.timestampField);
332
+ if (!softDeleteField || softDeleteField.type !== "Boolean") {
333
+ errors.push(`Collection ${collectionName} has a soft delete archived field ${softDelete.archivedField} that does not exist or is not a boolean`);
334
+ }
335
+ if (auth) {
336
+ errors.push(`Auth collection ${collectionName} cannot have soft delete enabled`);
337
+ }
338
+ if (!softDeleteTimestampField || softDeleteTimestampField.type !== "Timestamp") {
339
+ errors.push(`Collection ${collectionName} has a soft delete archivedAt field ${softDelete.timestampField} that does not exist or is not a timestamp`);
340
+ }
341
+ if (typeof softDelete.retentionPeriod !== "number") {
342
+ errors.push(`Collection ${collectionName} has a soft delete retention period that is not a number`);
343
+ }
344
+ if (softDeleteField?.sorting) {
345
+ errors.push(`Collection ${collectionName} has a soft delete field ${softDelete.archivedField} with sorting enabled`);
346
+ }
347
+ if (softDeleteTimestampField?.sorting) {
348
+ errors.push(`Collection ${collectionName} has a soft delete timestamp field ${softDelete.timestampField} with sorting enabled`);
349
+ }
350
+ if (softDeleteField?.access) {
351
+ errors.push(`Collection ${collectionName} has a soft delete field ${softDelete.archivedField} with access restrictions`);
352
+ }
353
+ if (softDeleteTimestampField?.access) {
354
+ errors.push(`Collection ${collectionName} has a soft delete timestamp field ${softDelete.timestampField} with access restrictions`);
355
+ }
356
+ }
357
+ if (fullTextSearch) {
358
+ for (const field of fullTextSearch) {
359
+ if (!fieldNames.includes(field)) {
360
+ errors.push(`Collection ${collectionName} has a full text search field ${field} that does not exist`);
361
+ }
362
+ else {
363
+ const fieldSchema = getField(fields, field);
364
+ if (fieldSchema.access) {
365
+ errors.push(`Collection ${collectionName} has a full text search field ${field} with access restrictions`);
366
+ }
367
+ }
368
+ }
369
+ if (access.entityRestrictions) {
370
+ for (const role of readRoles) {
371
+ if (!preloadCache?.roles.includes(role) && !access.serverReadOnly?.includes(role)) {
372
+ warnings.push(`Full text search will not work for role ${role} for collection ${collectionName} because the role has entity restrictions set. This can be resolved by enabling the preload cache or server read only options for the role.`);
373
+ }
374
+ }
375
+ }
376
+ }
377
+ if (ai?.chat) {
378
+ for (const role of ai.chat.roles) {
379
+ if (!roles.includes(role)) {
380
+ errors.push(`Collection ${collectionName} has a chat role ${role} that does not exist`);
381
+ }
382
+ }
383
+ }
384
+ const statusField = await tryPromise(customization?.admin?.statusField);
385
+ if (statusField) {
386
+ const statusFieldSchema = getField(fields, statusField.field);
387
+ if (!statusFieldSchema) {
388
+ errors.push(`Collection ${collectionName} has a status field ${statusField.field} that does not exist`);
389
+ }
390
+ else if (!((statusFieldSchema.type === "Boolean" &&
391
+ statusField.active.length === 1 &&
392
+ statusField.active[0] === true &&
393
+ statusField.archived.length === 1 &&
394
+ statusField.archived[0] === false) ||
395
+ ((statusFieldSchema.type === "String" || statusFieldSchema.type === "Number") &&
396
+ (!statusField.active ||
397
+ statusField.active.every((value) => statusFieldSchema.values?.includes(value))) &&
398
+ (!statusField.archived ||
399
+ statusField.archived.every((value) => statusFieldSchema.values?.includes(value)))))) {
400
+ errors.push(`Collection ${collectionName} has a status field ${statusField.field} with values that do not match the matching field's values`);
401
+ }
402
+ }
403
+ const defaultSort = (await tryPromise(customization?.admin?.defaultSort));
404
+ if (defaultSort) {
405
+ if (!fieldNames.includes(defaultSort.field)) {
406
+ errors.push(`Collection ${collectionName} has a default sort field ${defaultSort.field} that does not exist`);
407
+ }
408
+ }
409
+ const breadcrumbs = (await tryPromise(customization?.admin?.breadcrumbs));
410
+ if (breadcrumbs) {
411
+ for (const breadcrumb of breadcrumbs) {
412
+ if (!fieldNames.includes(breadcrumb)) {
413
+ errors.push(`Collection ${collectionName} has a breadcrumb ${breadcrumb} that does not exist`);
414
+ }
415
+ }
416
+ }
417
+ const cards = (await tryPromise(customization?.admin?.cards));
418
+ if (cards) {
419
+ if (cards.statusField && !fieldNames.includes(cards.statusField)) {
420
+ errors.push(`Collection ${collectionName} has a cards status field ${cards.statusField} that does not exist`);
421
+ }
422
+ if (!fieldNames.concat(systemFields).includes(cards.headerField)) {
423
+ errors.push(`Collection ${collectionName} has a cards header field ${cards.headerField} that does not exist`);
424
+ }
425
+ for (const section of cards.sections) {
426
+ for (const field of section.fields) {
427
+ if (!fieldNames.includes(field)) {
428
+ errors.push(`Collection ${collectionName} has a cards section with a field ${field} that does not exist`);
429
+ }
430
+ }
431
+ }
432
+ if (cards.footerField && !fieldNames.concat(systemFields).includes(cards.footerField)) {
433
+ errors.push(`Collection ${collectionName} has a cards footer field ${cards.footerField} that does not exist`);
434
+ }
435
+ if (!(cards.statusField || statusField)) {
436
+ errors.push(`Collection ${collectionName} has cards enabled but does not have a status field defined`);
437
+ }
438
+ for (const role of readRoles) {
439
+ if (statusField && cards.statusField && !preloadCache?.roles.includes(role)) {
440
+ warnings.push(`Collection ${collectionName} has a cards-level status field that will not work for role ${role} because preload cache is not enabled for that role`);
441
+ }
442
+ }
443
+ const statusFieldSchema = getField(fields, statusField?.field || cards.statusField);
444
+ if (cards.excludeValues) {
445
+ for (const value of cards.excludeValues) {
446
+ if (!statusFieldSchema?.values
447
+ ?.map((statusFieldValue) => statusFieldValue.toString())
448
+ .includes(value.toString())) {
449
+ errors.push(`Collection ${collectionName} has a cards exclude value ${value} that does not exist in the status field`);
450
+ }
451
+ }
452
+ }
453
+ }
454
+ const images = (await tryPromise(customization?.admin?.images));
455
+ if (images) {
456
+ if (!fieldNames.includes(images.imageField)) {
457
+ errors.push(`Collection ${collectionName} has an images image field ${images.imageField} that does not exist`);
458
+ }
459
+ }
460
+ const map = (await tryPromise(customization?.admin?.map));
461
+ if (map) {
462
+ if (map.addressField && !fieldNames.includes(map.addressField)) {
463
+ errors.push(`Collection ${collectionName} has a map location field ${map.addressField} that does not exist`);
464
+ }
465
+ if (map.coordinatesField && !fieldNames.includes(map.coordinatesField)) {
466
+ errors.push(`Collection ${collectionName} has a map coordinates field ${map.coordinatesField} that does not exist`);
467
+ }
468
+ if (map.addressField && map.coordinatesField) {
469
+ errors.push(`Collection ${collectionName} has both a map address field and a coordinates field`);
470
+ }
471
+ }
472
+ const calendar = (await tryPromise(customization?.admin?.calendar));
473
+ if (calendar) {
474
+ const startFieldSchema = getField(fields.concat(systemFieldSchema), calendar.startField);
475
+ if (!startFieldSchema) {
476
+ errors.push(`Collection ${collectionName} has a calendar start field ${calendar.startField} that does not exist`);
477
+ }
478
+ else {
479
+ if (startFieldSchema.type !== "Timestamp") {
480
+ errors.push(`Collection ${collectionName} has a calendar start field ${calendar.startField} that is not a Timestamp`);
481
+ }
482
+ if (!readRoles.every((role) => preloadCache?.roles.includes(role)) && !startFieldSchema.required) {
483
+ errors.push(`Collection ${collectionName} calendar start field ${calendar.startField} must be a required field`);
484
+ }
485
+ if (calendar.unscheduled) {
486
+ if (startFieldSchema.type !== "Timestamp" || !startFieldSchema.nullable) {
487
+ errors.push(`Collection ${collectionName} has the calendar unscheduled feature enabled but has a calendar start field ${calendar.startField} that is not nullable`);
488
+ }
489
+ }
490
+ }
491
+ if (calendar.endField) {
492
+ const endFieldSchema = getField(fields.concat(systemFieldSchema), calendar.endField);
493
+ if (!endFieldSchema) {
494
+ errors.push(`Collection ${collectionName} has a calendar end field ${calendar.endField} that does not exist`);
495
+ }
496
+ else if (endFieldSchema.type !== "Timestamp") {
497
+ errors.push(`Collection ${collectionName} has a calendar end field ${calendar.endField} that is not a Timestamp`);
498
+ }
499
+ }
500
+ if (calendar.allDayField) {
501
+ const allDayFieldSchema = getField(fields, calendar.allDayField);
502
+ if (!allDayFieldSchema) {
503
+ errors.push(`Collection ${collectionName} has a calendar all day field ${calendar.allDayField} that does not exist`);
504
+ }
505
+ else if (allDayFieldSchema.type !== "Boolean") {
506
+ errors.push(`Collection ${collectionName} has a calendar all day field ${calendar.allDayField} that is not a Boolean`);
507
+ }
508
+ }
509
+ if (calendar.resourceField) {
510
+ const resourceField = getField(fields, calendar.resourceField);
511
+ if (!resourceField) {
512
+ errors.push(`Collection ${collectionName} has a calendar resource field ${calendar.resourceField} that does not exist`);
513
+ }
514
+ else {
515
+ if (calendar.resourceTitleField) {
516
+ if (isRelationField(resourceField)) {
517
+ const relationCollection = schema.collections[resourceField.collection];
518
+ const titleField = getField(relationCollection.fields, calendar.resourceTitleField);
519
+ if (!titleField) {
520
+ errors.push(`Collection ${collectionName} has a calendar resource title field ${calendar.resourceTitleField} that does not exist`);
521
+ }
522
+ }
523
+ }
524
+ }
525
+ if (preloadCache?.range) {
526
+ const calendarCache = preloadCache.range.fields.find((field) => field === calendar.startField);
527
+ if (!calendarCache) {
528
+ warnings.push(`Collection ${collectionName} has a calendar start field ${calendar.startField} that does not have a matching preload cache field.`);
529
+ }
530
+ }
531
+ }
532
+ if (calendar.unscheduled) {
533
+ if (!preloadCache?.roles.length) {
534
+ errors.push(`Collection ${collectionName} uses the calendar unscheduled feature but does not have preload cache enabled`);
535
+ }
536
+ else {
537
+ if (calendar.unscheduled.roles) {
538
+ for (const role of calendar.unscheduled.roles) {
539
+ if (!preloadCache?.roles.includes(role)) {
540
+ errors.push(`Collection ${collectionName} has the calendar unscheduled feature enabled for role ${role} that does not also have the preload cache enabled`);
541
+ }
542
+ }
543
+ }
544
+ }
545
+ }
546
+ }
547
+ const restrictExport = (await tryPromise(customization?.admin?.restrictExport));
548
+ if (restrictExport) {
549
+ for (const role of restrictExport) {
550
+ if (!roles.includes(role)) {
551
+ errors.push(`Collection ${collectionName} has a restrict export role ${role} that does not exist`);
552
+ }
553
+ }
554
+ }
555
+ const metrics = (await tryPromise(customization?.admin?.metrics));
556
+ if (metrics) {
557
+ for (const metric of metrics) {
558
+ if (metric.type === "sum" || metric.type === "average") {
559
+ if (metric.field && !fieldNames.includes(metric.field)) {
560
+ errors.push(`Collection ${collectionName} has a metrics field ${metric.field} that does not exist`);
561
+ }
562
+ }
563
+ else if (metric.type === "area") {
564
+ if (!fieldNames.concat(systemFieldSchema.map((field) => field.name)).includes(metric.dateField)) {
565
+ errors.push(`Collection ${collectionName} has a chart date field ${metric.dateField} that does not exist`);
566
+ }
567
+ if (metric.metricField1 && !fieldNames.includes(metric.metricField1)) {
568
+ errors.push(`Collection ${collectionName} has a chart metric field ${metric.metricField1} that does not exist`);
569
+ }
570
+ if (metric.metricField2 && !fieldNames.includes(metric.metricField2)) {
571
+ errors.push(`Collection ${collectionName} has a chart metric field ${metric.metricField2} that does not exist`);
572
+ }
573
+ }
574
+ if (metric.roles) {
575
+ for (const role of metric.roles) {
576
+ if (!roles.includes(role)) {
577
+ errors.push(`Collection ${collectionName} has a metrics role ${role} that does not exist`);
578
+ }
579
+ }
580
+ }
581
+ }
582
+ }
583
+ const filters = (await tryPromise(customization?.admin?.filters));
584
+ if (filters) {
585
+ for (const filter of filters) {
586
+ if (filter.type === "status")
587
+ continue;
588
+ const field = getField(fields.concat(systemFieldSchema), filter.field);
589
+ if (!field) {
590
+ errors.push(`Collection ${collectionName} has a filter field ${filter.field} that does not exist`);
591
+ }
592
+ else {
593
+ if ("roles" in filter && filter.roles) {
594
+ for (const role of filter.roles) {
595
+ if (!roles.includes(role)) {
596
+ errors.push(`Collection ${collectionName} has a filter for field ${filter.field} that has an access role ${role} that does not exist`);
597
+ }
598
+ if (field.access && !field.access.includes(role)) {
599
+ errors.push(`Collection ${collectionName} has a filter for field ${filter.field} that has an access role ${role} that does not have access to the field`);
600
+ }
601
+ }
602
+ }
603
+ if (filter.type === "range" && field.type !== "Timestamp") {
604
+ errors.push(`Collection ${collectionName} has a filter field ${filter.field} that is not a Timestamp`);
605
+ }
606
+ if (filter.type === "range" && readRoles.every((role) => preloadCache?.roles.includes(role))) {
607
+ warnings.push(`Collection ${collectionName} does not require a range filter because preload cache has been enabled for all roles`);
608
+ }
609
+ else if (filter.type === "range" && calendar) {
610
+ warnings.push(`Collection ${collectionName} does not require a range filter because the calendar start field is automatically used as the range filter field`);
611
+ }
612
+ if (filter.type === "select" && !["Boolean", "String", "Number", "Array"].includes(field.type)) {
613
+ errors.push(`Collection ${collectionName} has a filter field ${filter.field} that is not a valid type for a select filter`);
614
+ }
615
+ if (filter.type === "relation" && !isRelationField(field)) {
616
+ errors.push(`Collection ${collectionName} has a filter field ${filter.field} that is not a valid type for a relation filter`);
617
+ }
618
+ if (filter.type === "relation" && isRelationField(field)) {
619
+ const relationCollection = schema.collections[field.collection];
620
+ if (!relationCollection.fullTextSearch) {
621
+ errors.push(`Collection ${collectionName} has a relation filter for field ${filter.field} on collection ${field.collection} that does not have full text search enabled`);
622
+ }
623
+ }
624
+ }
625
+ }
626
+ }
627
+ const rangeFilters = filters?.filter((filter) => filter.type === "range");
628
+ if (rangeFilters && rangeFilters.length > 1) {
629
+ errors.push(`Collection ${collectionName} has more than one range filter`);
630
+ }
631
+ const { auth: authAccess, operations, attributeRestrictions, entityRestrictions, permissionWriteRestrictions, serverReadOnly, serverWriteOnly, files, } = access;
632
+ if (authAccess) {
633
+ for (const role of authAccess) {
634
+ if (!roles.includes(role)) {
635
+ errors.push(`Collection ${collectionName} has an auth role ${role} that does not exist`);
636
+ }
637
+ }
638
+ }
639
+ if (serverReadOnly) {
640
+ for (const role of serverReadOnly) {
641
+ if (!roles.includes(role)) {
642
+ errors.push(`Collection ${collectionName} has a server read only role ${role} that does not exist`);
643
+ }
644
+ }
645
+ }
646
+ if (preloadCache && serverReadOnly) {
647
+ for (const role of roles) {
648
+ if (preloadCache?.roles.includes(role) && serverReadOnly.includes(role)) {
649
+ errors.push(`Collection ${collectionName} cannot have both preloadCache and serverReadOnly enabled for role ${role}`);
650
+ }
651
+ }
652
+ }
653
+ if (!(operations.assignable || operations.read || operations.create || operations.update || operations.delete)) {
654
+ errors.push(`Collection ${collectionName} has no access operations defined`);
655
+ }
656
+ if (typeof operations.assignable === "object") {
657
+ for (const role of operations.assignable) {
658
+ if (!roles.includes(role)) {
659
+ errors.push(`Collection ${collectionName} has an assignable access role ${role} that does not exist`);
660
+ }
661
+ }
662
+ }
663
+ if (operations.read) {
664
+ for (const role of operations.read) {
665
+ if (!roles.includes(role)) {
666
+ errors.push(`Collection ${collectionName} has a read access role ${role} that does not exist`);
667
+ }
668
+ }
669
+ }
670
+ if (operations.create) {
671
+ for (const role of operations.create) {
672
+ if (!roles.includes(role)) {
673
+ errors.push(`Collection ${collectionName} has a create access role ${role} that does not exist`);
674
+ }
675
+ }
676
+ }
677
+ if (operations.update) {
678
+ for (const role of operations.update) {
679
+ if (!roles.includes(role)) {
680
+ errors.push(`Collection ${collectionName} has an update access role ${role} that does not exist`);
681
+ }
682
+ }
683
+ }
684
+ if (operations.delete) {
685
+ for (const role of operations.delete) {
686
+ if (!roles.includes(role)) {
687
+ errors.push(`Collection ${collectionName} has a delete access role ${role} that does not exist`);
688
+ }
689
+ }
690
+ for (const role of operations.delete) {
691
+ for (const field of fields) {
692
+ if (field.access && !field.access.includes(role)) {
693
+ warnings.push(`Collection ${collectionName} can be deleted by role ${role}, who does not have access to field ${field.name}`);
694
+ }
695
+ }
696
+ }
697
+ }
698
+ if (entityRestrictions) {
699
+ if (entityRestrictions.assignable) {
700
+ for (const role of entityRestrictions.assignable) {
701
+ if (!roles.includes(role)) {
702
+ errors.push(`Collection ${collectionName} has an entity restriction assignable role ${role} that does not exist`);
703
+ }
704
+ }
705
+ }
706
+ if (entityRestrictions.restrictions) {
707
+ const restrictions = entityRestrictions.restrictions;
708
+ for (const restriction of restrictions) {
709
+ if (!["Individual", "Parent", "Parent_Property"].includes(restriction.type)) {
710
+ errors.push(`Collection ${collectionName} has an entity restriction ${restriction.type} with an invalid type ${restriction.type}`);
711
+ }
712
+ if ("roles" in restriction) {
713
+ for (const role of restriction.roles) {
714
+ if (!roles.includes(role.role)) {
715
+ errors.push(`Collection ${collectionName} has an entity restriction ${restriction.type} with role ${role.role} that does not exist`);
716
+ }
717
+ }
718
+ }
719
+ if (restriction.type === "Individual") {
720
+ if (collectionSchema.parentCollection) {
721
+ errors.push(`Collection ${collectionName} has an individual entity restriction but is a subcollection`);
722
+ }
723
+ }
724
+ if (restriction.type === "Parent" || restriction.type === "Parent_Property") {
725
+ const collectionField = getField(fields, restriction.collectionField);
726
+ if (!collectionField || !isRelationField(collectionField)) {
727
+ errors.push(`Collection ${collectionName} has an entity restriction ${restriction.type} with collection field ${restriction.collectionField} that does not exist or is not a relation field`);
728
+ }
729
+ else if (collectionField.restrictUpdate !== true) {
730
+ warnings.push(`Collection ${collectionName} has an entity restriction ${restriction.type} with collection field ${restriction.collectionField} that does not have restrictUpdate set to true`);
731
+ }
732
+ const relationCollection = schema.collections[collectionField.collection];
733
+ if (relationCollection.parentCollection) {
734
+ errors.push(`Collection ${collectionName} has an entity restriction ${restriction.type} with collection field ${restriction.collectionField} that is linked to a subcollection`);
735
+ }
736
+ }
737
+ if (restriction.type === "Parent_Property") {
738
+ const propertyField = getField(fields, restriction.propertyField);
739
+ if (!propertyField) {
740
+ errors.push(`Collection ${collectionName} has an entity restriction ${restriction.type} with property field ${restriction.propertyField} that does not exist`);
741
+ }
742
+ else {
743
+ if (propertyField.restrictUpdate !== true) {
744
+ warnings.push(`Collection ${collectionName} has an entity restriction ${restriction.type} with property field ${restriction.propertyField} that does not have restrictUpdate set to true`);
745
+ }
746
+ if (propertyField.type === "Map" || propertyField.type === "Array") {
747
+ errors.push(`Collection ${collectionName} has an entity restriction ${restriction.type} with property field ${restriction.propertyField} of invalid type ${propertyField.type}`);
748
+ }
749
+ if (!("values" in propertyField && propertyField.values)) {
750
+ errors.push(`Collection ${collectionName} has an entity restriction ${restriction.type} with property field ${restriction.propertyField} that does not have values set`);
751
+ }
752
+ }
753
+ }
754
+ }
755
+ for (const stokerRole of roles) {
756
+ const roleRestrictions = restrictions.filter((item) => item.roles.some((role) => role.role === stokerRole));
757
+ const individual = roleRestrictions.filter((item) => item.type === "Individual");
758
+ const parent = roleRestrictions.filter((item) => item.type === "Parent");
759
+ const parentProperty = roleRestrictions.filter((item) => item.type === "Parent_Property");
760
+ if (individual.length > 1) {
761
+ errors.push(`Collection ${collectionName} has more than one Individual entity restriction for role ${stokerRole}`);
762
+ }
763
+ if (parent.length > 1) {
764
+ errors.push(`Collection ${collectionName} has more than one Parent entity restriction for role ${stokerRole}`);
765
+ }
766
+ if (parentProperty.length > 1) {
767
+ errors.push(`Collection ${collectionName} has more than one Parent_Property entity restriction for role ${stokerRole}`);
768
+ }
769
+ if (parent.length && parentProperty.length) {
770
+ errors.push(`Collection ${collectionName} has both Parent and Parent_Property entity restrictions for role ${stokerRole}`);
771
+ }
772
+ const singleQueryRestrictions = restrictions.filter((item) => (item.type === "Individual" || item.type === "Parent") &&
773
+ item.roles.some((role) => role.role === stokerRole) &&
774
+ item.singleQuery);
775
+ singleQueryRestrictions.forEach((item) => {
776
+ if (preloadCache?.roles.some((role) => role === stokerRole) ||
777
+ serverReadOnly?.some((role) => role === stokerRole)) {
778
+ warnings.push(`Collection ${collectionName} has the singleQuery option set for entity restriction ${item.type} for role ${stokerRole}. This is not recommended when using preload cache or server read only.`);
779
+ }
780
+ });
781
+ if (roleRestrictions.length > 1 && singleQueryRestrictions.length > 0) {
782
+ errors.push(`Collection ${collectionName} has a combination of entity restrictions for role ${stokerRole} with ${singleQueryRestrictions.map((item) => item.type).join(" and ")} having the singleQuery option set`);
783
+ }
784
+ if (roleRestrictions.length > 0 &&
785
+ attributeRestrictions?.find((item) => item.type === "Record_User" && item.roles.some((role) => role.role === stokerRole))) {
786
+ errors.push(`Collection ${collectionName} cannot have both an entity restriction and a Record_User attribute restriction for role ${stokerRole}.`);
787
+ }
788
+ }
789
+ if (ai?.chat) {
790
+ for (const chatRole of ai.chat.roles) {
791
+ if (entityRestrictions.assignable?.includes(chatRole) ||
792
+ restrictions.some((restriction) => restriction.roles.some((role) => role.role === chatRole))) {
793
+ errors.push(`Collection ${collectionName} has AI chat enabled for role ${chatRole}, which also has entity restrictions.`);
794
+ }
795
+ }
796
+ }
797
+ }
798
+ if (entityRestrictions.parentFilters) {
799
+ const parentFilters = entityRestrictions.parentFilters;
800
+ const individual = parentFilters.filter((item) => item.type === "Individual");
801
+ const parent = parentFilters.filter((item) => item.type === "Parent");
802
+ const parentProperty = parentFilters.filter((item) => item.type === "Parent_Property");
803
+ for (const parentFilterItem of parentFilters) {
804
+ if (!["Individual", "Parent", "Parent_Property"].includes(parentFilterItem.type)) {
805
+ errors.push(`Collection ${collectionName} has a parent filter ${parentFilterItem.type} with an invalid type ${parentFilterItem.type}`);
806
+ }
807
+ if ("roles" in parentFilterItem) {
808
+ for (const role of parentFilterItem.roles) {
809
+ if (!roles.includes(role.role)) {
810
+ errors.push(`Collection ${collectionName} has a parent filter ${parentFilterItem.type} with role ${role.role} that does not exist`);
811
+ }
812
+ }
813
+ }
814
+ const collectionField = getField(fields, parentFilterItem.collectionField);
815
+ if (!collectionField || !isRelationField(collectionField)) {
816
+ errors.push(`Collection ${collectionName} has a parent filter ${parentFilterItem.type} with collection field ${parentFilterItem.collectionField} that does not exist or is not a relation field`);
817
+ }
818
+ if (parentFilterItem.type === "Parent" || parentFilterItem.type === "Parent_Property") {
819
+ const parentCollectionField = getField(fields, parentFilterItem.parentCollectionField);
820
+ if (!parentCollectionField || !isRelationField(parentCollectionField)) {
821
+ errors.push(`Collection ${collectionName} has a parent filter ${parentFilterItem.type} with parent collection field ${parentFilterItem.parentCollectionField} that does not exist or is not a relation field`);
822
+ }
823
+ else if (parentCollectionField.restrictUpdate !== true) {
824
+ warnings.push(`Collection ${collectionName} has a parent filter ${parentFilterItem.type} with parent collection field ${parentFilterItem.parentCollectionField} that does not have restrictUpdate set to true`);
825
+ }
826
+ }
827
+ if (parentFilterItem.type === "Parent_Property") {
828
+ const propertyField = getField(fields, parentFilterItem.parentPropertyField);
829
+ if (!propertyField) {
830
+ errors.push(`Collection ${collectionName} has a parent filter ${parentFilterItem.type} with property field ${parentFilterItem.parentPropertyField} that does not exist`);
831
+ }
832
+ else if (propertyField.restrictUpdate !== true) {
833
+ warnings.push(`Collection ${collectionName} has a parent filter ${parentFilterItem.type} with property field ${parentFilterItem.parentPropertyField} that does not have restrictUpdate set to true`);
834
+ if (propertyField.type === "Map" || propertyField.type === "Array") {
835
+ errors.push(`Collection ${collectionName} has a parent filter ${parentFilterItem.type} with property field ${parentFilterItem.parentPropertyField} of invalid type ${propertyField.type}`);
836
+ }
837
+ }
838
+ }
839
+ if (individual.length && parent.length) {
840
+ const individualItem = individual[0];
841
+ const parentItem = parent[0];
842
+ if (individualItem.collectionField !== parentItem.collectionField) {
843
+ errors.push(`Collection ${collectionName} has an Individual parent filter with collection field ${individualItem.collectionField} that does not match the Parent parent filter collection field ${parentItem.collectionField}`);
844
+ }
845
+ }
846
+ if (individual.length && parentProperty.length) {
847
+ const individualItem = individual[0];
848
+ const parentPropertyItem = parentProperty[0];
849
+ if (individualItem.collectionField !== parentPropertyItem.collectionField) {
850
+ errors.push(`Collection ${collectionName} has an Individual parent filter with collection field ${individualItem.collectionField} that does not match the Parent_Property parent filter collection field ${parentPropertyItem.collectionField}`);
851
+ }
852
+ }
853
+ }
854
+ for (const stokerRole of roles) {
855
+ const roleParentFilters = parentFilters?.filter((item) => item.roles.some((role) => role.role === stokerRole));
856
+ const roleRestrictions = entityRestrictions.restrictions?.filter((item) => item.roles.some((role) => role.role === stokerRole));
857
+ if (roleRestrictions && roleRestrictions.length > 0 && roleParentFilters.length > 0) {
858
+ errors.push(`Collection ${collectionName} cannot have both entity restrictions and parent filters for role ${stokerRole}`);
859
+ }
860
+ if (roleParentFilters.length > 0 &&
861
+ attributeRestrictions?.find((item) => item.type === "Record_User" && item.roles.some((role) => role.role === stokerRole))) {
862
+ errors.push(`Collection ${collectionName} cannot have both a parent filter and a Record_User attribute restriction for role ${stokerRole}.`);
863
+ }
864
+ const roleIndividual = roleParentFilters.filter((item) => item.type === "Individual");
865
+ const roleParent = roleParentFilters.filter((item) => item.type === "Parent");
866
+ const roleParentProperty = roleParentFilters.filter((item) => item.type === "Parent_Property");
867
+ if (roleIndividual.length > 1) {
868
+ errors.push(`Collection ${collectionName} has more than one Individual parent filter for role ${stokerRole}`);
869
+ }
870
+ if (roleParent.length > 1) {
871
+ errors.push(`Collection ${collectionName} has more than one Parent parent filter for role ${stokerRole}`);
872
+ }
873
+ if (roleParentProperty.length > 1) {
874
+ errors.push(`Collection ${collectionName} has more than one Parent_Property parent filter for role ${stokerRole}`);
875
+ }
876
+ if (roleParent.length && roleParentProperty.length) {
877
+ errors.push(`Collection ${collectionName} has both Parent and Parent_Property parent filters for role ${stokerRole}`);
878
+ }
879
+ for (const parentFilterItem of roleParentFilters) {
880
+ const parentField = getField(fields, parentFilterItem.collectionField);
881
+ if (parentField && isRelationField(parentField)) {
882
+ const parentCollection = schema.collections[parentField.collection];
883
+ const restriction = parentCollection.access.entityRestrictions?.restrictions?.find((restriction) => restriction.type === parentFilterItem.type &&
884
+ restriction.roles.some((role) => role.role === stokerRole));
885
+ if (!restriction) {
886
+ errors.push(`Collection ${collectionName} has a parent filter ${parentFilterItem.type} for role ${stokerRole} with collection field ${parentFilterItem.collectionField} that does not have a corresponding restriction`);
887
+ }
888
+ else {
889
+ if (parentFilterItem.type === "Parent" || parentFilterItem.type === "Parent_Property") {
890
+ const parentCollectionField = getField(fields, parentFilterItem.parentCollectionField);
891
+ const restrictionCollectionField = getField(parentCollection.fields, restriction
892
+ .collectionField);
893
+ if (parentCollectionField &&
894
+ restrictionCollectionField &&
895
+ isRelationField(parentCollectionField) &&
896
+ isRelationField(restrictionCollectionField) &&
897
+ parentCollectionField.collection !== restrictionCollectionField.collection) {
898
+ errors.push(`Collection ${collectionName} has a parent filter ${parentFilterItem.type} for role ${stokerRole} with parent collection field with collection ${parentCollectionField.collection} that does not match the collection for ${parentFilterItem.type} restriction collection field ${restriction.collectionField}`);
899
+ }
900
+ }
901
+ if (parentFilterItem.type === "Parent_Property") {
902
+ if (parentFilterItem.parentPropertyField !==
903
+ restriction.propertyField) {
904
+ errors.push(`Collection ${collectionName} has a parent filter for role ${stokerRole} with type ${parentFilterItem.type} and parent property field ${parentFilterItem.parentPropertyField} that does not match the ${parentFilterItem.type} restriction property field ${restriction.propertyField}`);
905
+ }
906
+ }
907
+ }
908
+ }
909
+ }
910
+ }
911
+ if (ai?.chat) {
912
+ for (const chatRole of ai.chat.roles) {
913
+ if (entityRestrictions.assignable?.includes(chatRole) ||
914
+ parentFilters.some((parentFilter) => parentFilter.roles.some((role) => role.role === chatRole))) {
915
+ errors.push(`Collection ${collectionName} has AI chat enabled for role ${chatRole}, which also has entity parent filters.`);
916
+ }
917
+ }
918
+ }
919
+ }
920
+ }
921
+ if (attributeRestrictions) {
922
+ for (const restriction of attributeRestrictions) {
923
+ if (!["Record_User", "Record_Owner", "Record_Property"].includes(restriction.type)) {
924
+ errors.push(`Collection ${collectionName} has an attribute restriction ${restriction.type} with an invalid type ${restriction.type}`);
925
+ }
926
+ if ("roles" in restriction) {
927
+ for (const role of restriction.roles) {
928
+ if (!roles.includes(role.role)) {
929
+ errors.push(`Collection ${collectionName} has an attribute restriction ${restriction.type} with role ${role.role} that does not exist`);
930
+ }
931
+ if ("propertyField" in restriction && role.values) {
932
+ for (const value of role.values) {
933
+ const propertyField = fields.find((field) => field.name === restriction.propertyField);
934
+ if (propertyField &&
935
+ propertyField.type !== "Number" &&
936
+ propertyField.type !== "Timestamp" &&
937
+ "values" in propertyField) {
938
+ const values = propertyField?.values;
939
+ if (!values?.includes(value)) {
940
+ errors.push(`Collection ${collectionName} has an attribute restriction ${restriction.type} with field value ${value} that does not exist`);
941
+ }
942
+ }
943
+ }
944
+ }
945
+ }
946
+ }
947
+ if ("propertyField" in restriction) {
948
+ const propertyField = getField(fields, restriction.propertyField);
949
+ if (!propertyField) {
950
+ errors.push(`Collection ${collectionName} has an attribute restriction ${restriction.type} with property field ${restriction.propertyField} that does not exist`);
951
+ }
952
+ else {
953
+ if (!["String", "Array"].includes(propertyField.type)) {
954
+ errors.push(`Collection ${collectionName} has an attribute restriction ${restriction.type} with property field ${restriction.propertyField} that is not a string or array`);
955
+ }
956
+ if ("values" in propertyField && propertyField.values && propertyField.values.length > 30) {
957
+ errors.push(`Collection ${collectionName} has an attribute restriction ${restriction.type} with property field ${restriction.propertyField} that has more than 30 values`);
958
+ }
959
+ }
960
+ }
961
+ if ("collectionField" in restriction) {
962
+ const collectionField = getField(fields, restriction.collectionField);
963
+ if (!collectionField) {
964
+ errors.push(`Collection ${collectionName} has an attribute restriction ${restriction.type} with collection field ${restriction.collectionField} that does not exist`);
965
+ }
966
+ else if (!collectionField.restrictUpdate) {
967
+ warnings.push(`Collection ${collectionName} has an attribute restriction ${restriction.type} with collection field ${restriction.collectionField} that does not have restrictUpdate set`);
968
+ }
969
+ }
970
+ }
971
+ }
972
+ if (ai?.chat) {
973
+ for (const chatRole of ai.chat.roles) {
974
+ if (attributeRestrictions?.some((attributeRestriction) => attributeRestriction.roles.some((role) => role.role === chatRole))) {
975
+ errors.push(`Collection ${collectionName} has AI chat enabled for role ${chatRole}, which also has attribute restrictions.`);
976
+ }
977
+ }
978
+ }
979
+ if (permissionWriteRestrictions?.length) {
980
+ for (const restriction of permissionWriteRestrictions) {
981
+ if (!roles.includes(restriction.userRole)) {
982
+ errors.push(`Collection ${collectionName} has an permission write restriction with user role ${restriction.userRole} that does not exist`);
983
+ }
984
+ if (!roles.includes(restriction.recordRole)) {
985
+ errors.push(`Collection ${collectionName} has an permission write restriction with record role ${restriction.recordRole} that does not exist`);
986
+ }
987
+ for (const collection of restriction.collections) {
988
+ const assignableCollection = schema.collections[collection.collection];
989
+ if (!assignableCollection) {
990
+ errors.push(`Collection ${collectionName} has an permission write restriction with collection ${collection.collection} that does not exist`);
991
+ }
992
+ else {
993
+ if (collection.attributeRestrictions) {
994
+ for (const restriction of collection.attributeRestrictions) {
995
+ if (!assignableCollection.access.attributeRestrictions?.find((restrictionItem) => restrictionItem.type === restriction)) {
996
+ errors.push(`Collection ${collectionName} has an permission write restriction with collection ${collection.collection} that has an attribute restriction ${restriction} that does not exist`);
997
+ }
998
+ }
999
+ }
1000
+ const permissionsCollection = schema.collections[collection.collection];
1001
+ if (!permissionsCollection.access.operations.assignable ||
1002
+ (Array.isArray(permissionsCollection.access.operations.assignable) &&
1003
+ !permissionsCollection.access.operations.assignable.includes(restriction.recordRole))) {
1004
+ const roleRead = !!permissionsCollection.access.operations.read?.includes(restriction.recordRole);
1005
+ const roleCreate = !!permissionsCollection.access.operations.create?.includes(restriction.recordRole);
1006
+ const roleUpdate = !!permissionsCollection.access.operations.update?.includes(restriction.recordRole);
1007
+ const roleDelete = !!permissionsCollection.access.operations.delete?.includes(restriction.recordRole);
1008
+ const restrictionRead = !!collection.operations.includes("Read");
1009
+ const restrictionCreate = !!collection.operations.includes("Create");
1010
+ const restrictionUpdate = !!collection.operations.includes("Update");
1011
+ const restrictionDelete = !!collection.operations.includes("Delete");
1012
+ if (roleRead !== restrictionRead ||
1013
+ roleCreate !== restrictionCreate ||
1014
+ roleUpdate !== restrictionUpdate ||
1015
+ roleDelete !== restrictionDelete) {
1016
+ errors.push(`Collection ${collectionName} has a permission write restriction for record role ${restriction.recordRole} that does not match the non-assignable access operations for collection ${collection.collection}`);
1017
+ }
1018
+ }
1019
+ }
1020
+ }
1021
+ }
1022
+ }
1023
+ if (operations.read && errors.length === 0) {
1024
+ for (const role of operations.read) {
1025
+ const paginationEnabled = isPaginationEnabled(role, collectionSchema, schema);
1026
+ if (paginationEnabled !== true &&
1027
+ !(preloadCache?.roles.includes(role) || serverReadOnly?.includes(role))) {
1028
+ warnings.push(`The admin app requires collection ${collectionName} to have preloadCache or serverReadOnly enabled for role ${role}. This is because the ${paginationEnabled} is enabled for this role and the singleQuery option is not set.`);
1029
+ }
1030
+ }
1031
+ }
1032
+ if (files?.assignment) {
1033
+ for (const role of Object.keys(files.assignment)) {
1034
+ if (!roles.includes(role)) {
1035
+ errors.push(`Collection ${collectionName} has a file assignment with role ${role} that does not exist`);
1036
+ }
1037
+ // eslint-disable-next-line security/detect-object-injection
1038
+ const assignmentValues = files.assignment[role];
1039
+ for (const operation of ["read", "update", "delete"]) {
1040
+ if (assignmentValues.optional) {
1041
+ for (const value of assignmentValues.optional[operation] ||
1042
+ []) {
1043
+ if (assignmentValues.required?.[operation]?.includes(value)) {
1044
+ errors.push(`Collection ${collectionName} has a file assignment with both optional and required ${operation} role ${value}`);
1045
+ }
1046
+ if (!roles.includes(value)) {
1047
+ errors.push(`Collection ${collectionName} has a file assignment with optional ${operation} role ${value} that does not exist`);
1048
+ }
1049
+ }
1050
+ }
1051
+ if (assignmentValues.required) {
1052
+ for (const value of assignmentValues.required[operation] ||
1053
+ []) {
1054
+ if (!roles.includes(value)) {
1055
+ errors.push(`Collection ${collectionName} has a file assignment with required ${operation} role ${value} that does not exist`);
1056
+ }
1057
+ }
1058
+ }
1059
+ }
1060
+ }
1061
+ }
1062
+ const allAccessFields = new Set();
1063
+ for (const role of roles) {
1064
+ const accessFields = getAccessFields(collectionSchema, role);
1065
+ for (const field of accessFields) {
1066
+ allAccessFields.add(field);
1067
+ if (field.access && !field.access.includes(role)) {
1068
+ errors.push(`Role ${role} requires access to field ${field.name}, as it is required for access control.`);
1069
+ }
1070
+ }
1071
+ }
1072
+ for (const field of allAccessFields) {
1073
+ if (!field.required) {
1074
+ warnings.push(`Collection ${collectionName} has a field ${field.name} that is required for access control but is not required`);
1075
+ }
1076
+ }
1077
+ for (const field of fields) {
1078
+ if (isDependencyField(field, collectionSchema, schema)) {
1079
+ const dependencyFields = getDependencyIndexFields(field, collectionSchema, schema);
1080
+ for (const dependencyField of dependencyFields) {
1081
+ if (dependencyField.access) {
1082
+ errors.push(`Collection ${collectionName} has a dependency index field ${dependencyField.name} that has access restrictions`);
1083
+ }
1084
+ }
1085
+ }
1086
+ }
1087
+ for (const field of fields) {
1088
+ const { name, type, required, sorting, access, restrictCreate, restrictUpdate } = field;
1089
+ const fieldCustomization = getFieldCustomization(field, customization);
1090
+ if (!(new TextEncoder().encode(name).length <= 1500)) {
1091
+ errors.push(`Invalid field name: ${name}. Must be a valid Firestore field name - less than 1,500 bytes.`);
1092
+ }
1093
+ const simpleRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
1094
+ if (!simpleRegex.test(name)) {
1095
+ errors.push(`Invalid field name: ${name}. Must be a simple Firestore field name.`);
1096
+ }
1097
+ const regex = /^[^.[\]*`]+$/;
1098
+ if (!regex.test(name)) {
1099
+ errors.push(`Invalid field name: ${name}. Must not contain any of the following characters: . [ ] * \``);
1100
+ }
1101
+ if ((("minlength" in field && field.minlength) || ("maxlength" in field && field.maxlength)) &&
1102
+ "length" in field &&
1103
+ field.length) {
1104
+ errors.push(`Collection ${collectionName} has a field ${name} with both length and minlength or maxlength set`);
1105
+ }
1106
+ if ("autoIncrement" in field && field.autoIncrement && field.decimal) {
1107
+ errors.push(`Collection ${collectionName} has a field ${name} with both auto increment and decimal set`);
1108
+ }
1109
+ if (field.restrictCreate && required) {
1110
+ errors.push(`Collection ${collectionName} has a field ${name} with both restrict create and required set`);
1111
+ }
1112
+ if (![
1113
+ "Boolean",
1114
+ "String",
1115
+ "Number",
1116
+ "Timestamp",
1117
+ "Array",
1118
+ "Map",
1119
+ "OneToOne",
1120
+ "OneToMany",
1121
+ "ManyToOne",
1122
+ "ManyToMany",
1123
+ "Embedding",
1124
+ "Computed",
1125
+ ].includes(type)) {
1126
+ errors.push(`Collection ${collectionName} has a field ${name} with an invalid type ${type}`);
1127
+ }
1128
+ if (access) {
1129
+ for (const role of access) {
1130
+ if (!roles.includes(role)) {
1131
+ errors.push(`Collection ${collectionName} has a field ${name} with access role ${role} that does not exist`);
1132
+ }
1133
+ }
1134
+ }
1135
+ if (typeof restrictCreate === "object") {
1136
+ for (const role of restrictCreate) {
1137
+ if (!roles.includes(role)) {
1138
+ errors.push(`Collection ${collectionName} has a field ${name} with restrict create role ${role} that does not exist`);
1139
+ }
1140
+ }
1141
+ }
1142
+ if (typeof restrictUpdate === "object") {
1143
+ for (const role of restrictUpdate) {
1144
+ if (!roles.includes(role)) {
1145
+ errors.push(`Collection ${collectionName} has a field ${name} with restrict update role ${role} that does not exist`);
1146
+ }
1147
+ }
1148
+ }
1149
+ if (required) {
1150
+ const createRoles = operations.create || [];
1151
+ for (const role of createRoles) {
1152
+ if (access && !access.includes(role)) {
1153
+ errors.push(`Collection ${collectionName} has a required field ${name} that is not accessible to role ${role} which has create access`);
1154
+ }
1155
+ }
1156
+ }
1157
+ const isLocation = await tryPromise(fieldCustomization.admin?.location);
1158
+ if (field.type === "Array" && isLocation && !field.nullable) {
1159
+ errors.push(`Collection ${collectionName} has a location field ${name} that is not nullable`);
1160
+ }
1161
+ if (type === "String" && fieldCustomization?.admin?.image) {
1162
+ const image = await tryPromise(fieldCustomization.admin.image);
1163
+ if (image && !field.pattern) {
1164
+ warnings.push(`Collection ${collectionName} field ${name} is an image field and should have the pattern property set to a valid URL pattern to prevent XSS attacks`);
1165
+ }
1166
+ }
1167
+ if (type === "String") {
1168
+ const validationProperties = ["ip", "url", "email", "uuid", "emoji", "pattern"].filter((prop) => prop in field && field[prop]);
1169
+ if (validationProperties.length > 1) {
1170
+ errors.push(`Collection ${collectionName} has a string field ${name} with multiple validation properties (${validationProperties.join(", ")}). Only one of ip, url, email, uuid, emoji, or pattern is allowed.`);
1171
+ }
1172
+ }
1173
+ if (isRelationField(field)) {
1174
+ if (!collectionNames.includes(field.collection)) {
1175
+ errors.push(`Collection ${collectionName} has a relation field ${name} with a collection ${field.collection} that does not exist`);
1176
+ console.warn(warnings.join("\n"));
1177
+ console.error(errors.join("\n"));
1178
+ process.exit(1);
1179
+ }
1180
+ const relationCollection = schema.collections[field.collection];
1181
+ if (required && !field.dependencyFields) {
1182
+ warnings.push(`Collection ${collectionName} has a required relation field ${name} with no dependency fields`);
1183
+ }
1184
+ if (field.titleField && !field.includeFields?.includes(field.titleField)) {
1185
+ errors.push(`Collection ${collectionName} has a relation field ${name} with a title field ${field.titleField} that is not included in the include fields`);
1186
+ }
1187
+ if (field.titleField) {
1188
+ const titleFieldSchema = getField(relationCollection.fields, field.titleField);
1189
+ if (!titleFieldSchema) {
1190
+ errors.push(`Collection ${collectionName} has a relation field ${name} with a title field ${field.titleField} that does not exist`);
1191
+ }
1192
+ else if (titleFieldSchema.type !== "String") {
1193
+ errors.push(`Collection ${collectionName} has a relation field ${name} with a title field ${field.titleField} that is not a string`);
1194
+ }
1195
+ }
1196
+ if ((("min" in field && field.min) || ("max" in field && field.max)) &&
1197
+ "length" in field &&
1198
+ field.length) {
1199
+ errors.push(`Collection ${collectionName} has a field ${name} with both length and min or max set`);
1200
+ }
1201
+ if (field.dependencyFields) {
1202
+ for (const dependencyField of field.dependencyFields) {
1203
+ const dependencyFields = relationCollection.fields.map((field) => field.name);
1204
+ if (!dependencyFields.includes(dependencyField.field)) {
1205
+ errors.push(`Collection ${collectionName} has a relation field ${name} with a dependency field ${dependencyField.field} that does not exist`);
1206
+ }
1207
+ for (const role of dependencyField.roles) {
1208
+ if (!roles.includes(role)) {
1209
+ errors.push(`Collection ${collectionName} has a relation field ${name} with a dependency field ${dependencyField.field} with role ${role} that does not exist`);
1210
+ }
1211
+ }
1212
+ const dependencyFieldSchema = getField(relationCollection.fields, dependencyField.field);
1213
+ if (dependencyFieldSchema &&
1214
+ isDependencyField(dependencyFieldSchema, relationCollection, schema) &&
1215
+ dependencyFieldSchema.access) {
1216
+ const error = `Collection ${relationCollection.labels.collection} has a relation field ${dependencyField.field} that has both dependent collections and access restrictions`;
1217
+ if (!errors.includes(error)) {
1218
+ errors.push(error);
1219
+ }
1220
+ }
1221
+ }
1222
+ }
1223
+ if (field.includeFields) {
1224
+ for (const includeField of field.includeFields) {
1225
+ const includeFieldSchema = getField(relationCollection.fields, includeField);
1226
+ if (!includeFieldSchema) {
1227
+ errors.push(`Collection ${collectionName} has a relation field ${name} with an include field ${includeField} that does not exist`);
1228
+ }
1229
+ else {
1230
+ if (isIncludedField(includeFieldSchema, relationCollection, schema) &&
1231
+ includeFieldSchema.access) {
1232
+ const error = `Collection ${relationCollection.labels.collection} has a relation field ${includeField} that is both used in include fields and has access restrictions`;
1233
+ if (!errors.includes(error)) {
1234
+ errors.push(error);
1235
+ }
1236
+ }
1237
+ if (isRelationField(includeFieldSchema)) {
1238
+ errors.push(`Collection ${collectionName} has a relation field ${name} with an include field ${includeField} that is also a relation field`);
1239
+ }
1240
+ if (!includeFieldSchema.required) {
1241
+ warnings.push(`Collection ${collectionName} has a relation field ${name} with an include field ${includeField} that is not required`);
1242
+ }
1243
+ }
1244
+ }
1245
+ }
1246
+ if (field.enforceHierarchy) {
1247
+ const { field: enforceField, recordLinkField } = field.enforceHierarchy;
1248
+ const enforceFieldSchema = getField(fields, enforceField);
1249
+ const recordLinkFieldSchema = getField(relationCollection.fields, recordLinkField);
1250
+ if (!enforceFieldSchema) {
1251
+ errors.push(`Collection ${collectionName} has a relation field ${name} with an enforce hierarchy field ${enforceField} that does not exist`);
1252
+ }
1253
+ else {
1254
+ if (!required) {
1255
+ errors.push(`Collection ${collectionName} has a relation field ${name} that has enforce hierarchy enabled but is not required`);
1256
+ }
1257
+ if (!enforceFieldSchema.required) {
1258
+ errors.push(`Collection ${collectionName} has a relation field ${name} with an enforce hierarchy field ${enforceField} that is not required`);
1259
+ }
1260
+ if (!["OneToOne", "OneToMany"].includes(enforceFieldSchema.type)) {
1261
+ errors.push(`Collection ${collectionName} has a relation field ${name} with an enforce hierarchy field ${enforceField} that is not a one to one or one to many relation`);
1262
+ }
1263
+ }
1264
+ if (!recordLinkFieldSchema) {
1265
+ errors.push(`Collection ${collectionName} has a relation field ${name} with an enforce hierarchy record link field ${recordLinkField} that does not exist`);
1266
+ }
1267
+ else if (!recordLinkFieldSchema.required) {
1268
+ errors.push(`Collection ${collectionName} has a relation field ${name} with an enforce hierarchy record link field ${recordLinkField} that is not required`);
1269
+ }
1270
+ }
1271
+ if (field.twoWay) {
1272
+ if (!serverWriteOnly) {
1273
+ errors.push(`Collection ${collectionName} has a two way relation field ${name} but does not have server write only set`);
1274
+ }
1275
+ const twoWayField = getField(relationCollection.fields, field.twoWay);
1276
+ if (!twoWayField) {
1277
+ errors.push(`Collection ${collectionName} has a relation field ${name} with a two way field ${field.twoWay} that does not match`);
1278
+ }
1279
+ else {
1280
+ if (isRelationField(twoWayField) && (!twoWayField || twoWayField.twoWay !== name)) {
1281
+ errors.push(`Collection ${collectionName} has a relation field ${name} with a two way field ${field.twoWay} that does not match`);
1282
+ }
1283
+ else {
1284
+ if (!isRelationField(twoWayField)) {
1285
+ errors.push(`Collection ${collectionName} has a relation field ${name} with a two way field ${field.twoWay} that is not a relation field`);
1286
+ }
1287
+ else {
1288
+ if (twoWayField.type !== getInverseRelationType(field.type)) {
1289
+ errors.push(`Collection ${collectionName} has a relation field ${name} with a two way field ${field.twoWay} that does not have the correct type`);
1290
+ }
1291
+ if (twoWayField.collection !== collectionName) {
1292
+ errors.push(`Collection ${collectionName} has a relation field ${name} with a two way field ${field.twoWay} that does not have the correct collection`);
1293
+ }
1294
+ }
1295
+ }
1296
+ if (field.restrictUpdate) {
1297
+ warnings.push(`Collection ${collectionName} has a relation field ${name} with a two way field ${field.twoWay} that has restrictUpdate set`);
1298
+ }
1299
+ if (field.required && !field.preserve) {
1300
+ warnings.push(`Collection ${collectionName} has a relation field ${name} that is required but does not have the preserve option set. This means that relations will be removed when the ${field.collection} record is deleted, potentially leaving required fields empty.`);
1301
+ }
1302
+ const twoWayCollection = schema.collections[field.collection];
1303
+ const { access: twoWayAccess } = twoWayCollection;
1304
+ if (twoWayAccess.operations.assignable) {
1305
+ warnings.push(`Collection ${collectionName} has a two way relation field ${name} with collection ${field.collection} that has assignable operations set`);
1306
+ }
1307
+ if (twoWayAccess.entityRestrictions?.restrictions?.length ||
1308
+ twoWayAccess.entityRestrictions?.parentFilters?.length) {
1309
+ warnings.push(`Collection ${collectionName} has a two way relation field ${name} with collection ${field.collection} that has entity restrictions set`);
1310
+ }
1311
+ if (twoWayAccess.attributeRestrictions?.length) {
1312
+ warnings.push(`Collection ${collectionName} has a two way relation field ${name} with collection ${field.collection} that has attribute restrictions set`);
1313
+ }
1314
+ }
1315
+ }
1316
+ if (["OneToOne", "OneToMany"].includes(type) && (field.min || field.max || field.length)) {
1317
+ errors.push(`Collection ${collectionName} has a ${type} field ${name} with min, max, or length set`);
1318
+ }
1319
+ }
1320
+ if ("unique" in field && field.unique) {
1321
+ if (!["String", "Number", "Timestamp"].includes(type)) {
1322
+ errors.push(`Collection ${collectionName} has a unique field ${name} that is not a string or number or timestamp`);
1323
+ }
1324
+ if (field.values) {
1325
+ errors.push(`Collection ${collectionName} has a unique field ${name} that also has values set`);
1326
+ }
1327
+ }
1328
+ if (sorting) {
1329
+ if (isRelationField(field) && !field.titleField) {
1330
+ errors.push(`Collection ${collectionName} has sorting enabled for relation field ${name}, but no title field has been set`);
1331
+ }
1332
+ if (typeof sorting === "object" && sorting.roles) {
1333
+ for (const role of sorting.roles) {
1334
+ if (!roles.includes(role)) {
1335
+ errors.push(`Collection ${collectionName} has sorting enabled for field ${name} with role ${role} that does not exist`);
1336
+ }
1337
+ if (field.access && !field.access.includes(role)) {
1338
+ errors.push(`Collection ${collectionName} has sorting enabled for field ${name} with role ${role} that does not have access to the field`);
1339
+ }
1340
+ }
1341
+ }
1342
+ }
1343
+ }
1344
+ for (const role of roles) {
1345
+ if (operations.assignable === true ||
1346
+ (typeof operations.assignable === "object" && operations.assignable.includes(role)) ||
1347
+ operations.read?.includes(role)) {
1348
+ let disjunctions = 0;
1349
+ let hasArrayContains = false;
1350
+ let profileProcessed = false;
1351
+ const incrementDisjunctions = (value) => {
1352
+ if (!value)
1353
+ return;
1354
+ if (disjunctions === 0) {
1355
+ disjunctions = value;
1356
+ }
1357
+ else {
1358
+ disjunctions *= value;
1359
+ }
1360
+ };
1361
+ if (attributeRestrictions) {
1362
+ for (const restriction of attributeRestrictions) {
1363
+ if (restriction.operations && !restriction.operations.includes("Read"))
1364
+ continue;
1365
+ if (restriction.roles.some((roleItem) => roleItem.role === role)) {
1366
+ if (restriction.type === "Record_Property") {
1367
+ const propertyRole = restriction.roles.find((roleItem) => roleItem.role === role);
1368
+ const propertyField = getField(fields, restriction.propertyField);
1369
+ if (propertyField.type === "Array") {
1370
+ if (hasArrayContains) {
1371
+ errors.push(`Collection ${collectionName} cannot have both a Record_User entity restriction and a Record_Property attribute restriction on an Array field, for role ${role}.`);
1372
+ }
1373
+ hasArrayContains = true;
1374
+ }
1375
+ incrementDisjunctions(propertyRole?.values?.length);
1376
+ }
1377
+ if (restriction.type === "Record_User") {
1378
+ if (hasArrayContains) {
1379
+ errors.push(`Collection ${collectionName} cannot have both a Record_User entity restriction and a Record_Property attribute restriction on an Array field, for role ${role}.`);
1380
+ }
1381
+ hasArrayContains = true;
1382
+ }
1383
+ }
1384
+ }
1385
+ }
1386
+ if (entityRestrictions?.restrictions) {
1387
+ for (const restriction of entityRestrictions.restrictions) {
1388
+ if (restriction.roles.some((roleItem) => roleItem.role === role)) {
1389
+ if (restriction.type === "Individual") {
1390
+ if (restriction.singleQuery && !profileProcessed) {
1391
+ incrementDisjunctions(restriction.singleQuery);
1392
+ profileProcessed = true;
1393
+ }
1394
+ }
1395
+ else if (restriction.type === "Parent") {
1396
+ if (restriction.singleQuery && !profileProcessed) {
1397
+ incrementDisjunctions(restriction.singleQuery);
1398
+ profileProcessed = true;
1399
+ }
1400
+ hasArrayContains = true;
1401
+ }
1402
+ }
1403
+ }
1404
+ }
1405
+ if (entityRestrictions?.parentFilters) {
1406
+ for (const parentFilterItem of entityRestrictions.parentFilters) {
1407
+ if (parentFilterItem.roles.some((roleItem) => roleItem.role === role)) {
1408
+ const collectionFieldSchema = getField(fields, parentFilterItem.collectionField);
1409
+ if (!isRelationField(collectionFieldSchema))
1410
+ continue;
1411
+ const parentCollection = schema.collections[collectionFieldSchema.collection];
1412
+ const matchingAssignment = parentCollection.access.entityRestrictions?.restrictions?.find((restriction) => restriction.type === parentFilterItem.type &&
1413
+ restriction.roles.some((roleItem) => roleItem.role === role));
1414
+ if (!(matchingAssignment?.type === "Individual" || matchingAssignment?.type === "Parent"))
1415
+ continue;
1416
+ if (parentFilterItem.type === "Individual") {
1417
+ if (matchingAssignment?.singleQuery && !profileProcessed) {
1418
+ incrementDisjunctions(matchingAssignment.singleQuery);
1419
+ profileProcessed = true;
1420
+ }
1421
+ hasArrayContains = true;
1422
+ }
1423
+ else if (parentFilterItem.type === "Parent") {
1424
+ if (matchingAssignment?.singleQuery && !profileProcessed) {
1425
+ incrementDisjunctions(matchingAssignment.singleQuery);
1426
+ profileProcessed = true;
1427
+ }
1428
+ hasArrayContains = true;
1429
+ }
1430
+ else if (parentFilterItem.type === "Parent_Property") {
1431
+ hasArrayContains = true;
1432
+ }
1433
+ }
1434
+ }
1435
+ }
1436
+ if (preloadCache?.roles.includes(role)) {
1437
+ if (preloadCache.range) {
1438
+ incrementDisjunctions(preloadCache.range.fields.length);
1439
+ }
1440
+ }
1441
+ if (statusField && !preloadCache?.roles.includes(role)) {
1442
+ incrementDisjunctions(Math.max(statusField.active?.length || 0, statusField.archived?.length || 0));
1443
+ }
1444
+ if (filters && !preloadCache?.roles.includes(role)) {
1445
+ for (const filter of filters) {
1446
+ if (filter.type === "range")
1447
+ continue;
1448
+ if (filter.roles && !filter.roles.includes(role))
1449
+ continue;
1450
+ if (filter.type === "relation" && hasArrayContains) {
1451
+ errors.push(`Collection ${collectionName} has a relation filter for role ${role} on field ${filter.field} that uses an array contains filter, but an array-contains filter has already been used. This can be resolved by using the preload cache.`);
1452
+ }
1453
+ if (filter.type === "select") {
1454
+ const field = getField(fields, filter.field);
1455
+ if (field.type === "Array" && hasArrayContains) {
1456
+ errors.push(`Collection ${collectionName} has a select filter for role ${role} on field ${filter.field} that uses an array contains filter, but an array-contains filter has already been used. This can be resolved by using the preload cache.`);
1457
+ }
1458
+ }
1459
+ }
1460
+ }
1461
+ const batchSize = disjunctions === 0 ? 30 : Math.max(1, Math.floor(30 / disjunctions));
1462
+ if (disjunctions > 30) {
1463
+ errors.push(`Collection ${collectionName} for role ${role} has ${disjunctions} disjunctions. The limit set by Firestore is 30.`);
1464
+ }
1465
+ else if (batchSize < 5) {
1466
+ warnings.push(`Collection ${collectionName} for role ${role} will be loaded in batches of ${batchSize}. This is less than the recommended minimum of 5.`);
1467
+ }
1468
+ }
1469
+ }
1470
+ }
1471
+ if (!authCollectionFound) {
1472
+ errors.push("No auth collection found");
1473
+ }
1474
+ const formattedWarnings = warnings.map((warning) => {
1475
+ return `WARN: ${warning}`;
1476
+ });
1477
+ if (errors.length) {
1478
+ const formattedErrors = errors.map((error) => {
1479
+ return `ERROR: ${error}`;
1480
+ });
1481
+ console.warn(formattedWarnings.join("\n"));
1482
+ console.error(formattedErrors.join("\n"));
1483
+ process.exit(1);
1484
+ }
1485
+ if (!noLog) {
1486
+ console.warn(formattedWarnings.join("\n"));
1487
+ console.log("Schema linted successfully.");
1488
+ process.exit();
1489
+ }
1490
+ return;
1491
+ };