@stndrds/schema 0.1.0-alpha.16 → 0.1.0-alpha.17

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/dist/runtime.js CHANGED
@@ -134,6 +134,7 @@ __export(runtime_exports, {
134
134
  ViewService: () => ViewService,
135
135
  buildAuditChanges: () => buildAuditChanges,
136
136
  createMockAdapter: () => createMockAdapter,
137
+ enrichValuesWithSelectLabels: () => enrichValuesWithSelectLabels,
137
138
  extractAttributeNames: () => extractAttributeNames,
138
139
  getSyncPreview: () => getSyncPreview,
139
140
  getViewSyncPreview: () => getViewSyncPreview,
@@ -165,6 +166,168 @@ function generateId() {
165
166
  });
166
167
  }
167
168
 
169
+ // src/format.ts
170
+ var import_constants = require("@stndrds/constants");
171
+ var EMPTY_VALUE_PLACEHOLDER = "\u2014";
172
+ function formatText(value) {
173
+ return String(value);
174
+ }
175
+ function formatCheckbox(value) {
176
+ return value ? "Yes" : "No";
177
+ }
178
+ function formatNumber(value, attribute) {
179
+ if (typeof value !== "number") return String(value);
180
+ const decimals = attribute.decimals;
181
+ return value.toLocaleString(void 0, {
182
+ minimumFractionDigits: decimals,
183
+ maximumFractionDigits: decimals
184
+ });
185
+ }
186
+ function formatCurrency(value, _attribute) {
187
+ if (typeof value !== "object" || value === null) return String(value);
188
+ const currency = value;
189
+ if (!("value" in currency && "code" in currency)) return String(value);
190
+ const formattedValue = currency.value.toLocaleString(void 0, {
191
+ minimumFractionDigits: 2,
192
+ maximumFractionDigits: 2
193
+ });
194
+ return `${formattedValue} ${currency.code}`;
195
+ }
196
+ function formatDate(value) {
197
+ if (value instanceof Date) {
198
+ return value.toISOString().split("T")[0];
199
+ }
200
+ if (typeof value === "string") {
201
+ const date = new Date(value);
202
+ if (!Number.isNaN(date.getTime())) {
203
+ return date.toISOString().split("T")[0];
204
+ }
205
+ }
206
+ return String(value);
207
+ }
208
+ function formatTimestamp(value) {
209
+ if (typeof value === "number") {
210
+ return new Date(value).toISOString();
211
+ }
212
+ if (value instanceof Date) {
213
+ return value.toISOString();
214
+ }
215
+ return String(value);
216
+ }
217
+ function formatPhone(value) {
218
+ if (typeof value !== "object" || value === null) return String(value);
219
+ const phone = value;
220
+ if (!("phoneNumber" in phone)) return String(value);
221
+ if (phone.countryCode) {
222
+ const country = (0, import_constants.getCountryByIso3)(phone.countryCode);
223
+ const dial = country?.phoneCode ?? "";
224
+ return `${dial} ${phone.phoneNumber}`.trim();
225
+ }
226
+ return phone.phoneNumber;
227
+ }
228
+ function formatLocation(value, attribute) {
229
+ if (typeof value !== "object" || value === null) return String(value);
230
+ const loc = value;
231
+ const granularity = attribute.granularity ?? "full";
232
+ const parts = [];
233
+ switch (granularity) {
234
+ case "country":
235
+ if (loc.country) parts.push(loc.country);
236
+ break;
237
+ case "state":
238
+ if (loc.state) parts.push(loc.state);
239
+ if (loc.country) parts.push(loc.country);
240
+ break;
241
+ case "city":
242
+ if (loc.city) parts.push(loc.city);
243
+ if (loc.state) parts.push(loc.state);
244
+ if (loc.country) parts.push(loc.country);
245
+ break;
246
+ case "coordinates":
247
+ if (loc.latitude !== void 0 && loc.longitude !== void 0) {
248
+ parts.push(`${loc.latitude}, ${loc.longitude}`);
249
+ }
250
+ break;
251
+ case "address":
252
+ if (loc.address) parts.push(loc.address);
253
+ if (loc.city) parts.push(loc.city);
254
+ if (loc.state) parts.push(loc.state);
255
+ if (loc.country) parts.push(loc.country);
256
+ break;
257
+ default:
258
+ if (loc.address) parts.push(loc.address);
259
+ if (loc.city) parts.push(loc.city);
260
+ if (loc.state) parts.push(loc.state);
261
+ if (loc.postalCode) parts.push(loc.postalCode);
262
+ if (loc.country) parts.push(loc.country);
263
+ break;
264
+ }
265
+ return parts.join(", ") || EMPTY_VALUE_PLACEHOLDER;
266
+ }
267
+ function formatSelect(value, attribute) {
268
+ if (typeof value !== "string") return String(value);
269
+ const option = attribute.options?.find((o) => o.value === value);
270
+ return option?.label ?? String(value);
271
+ }
272
+ function formatMultiselect(value, attribute) {
273
+ if (!Array.isArray(value)) return String(value);
274
+ if (attribute.options) {
275
+ const labels = value.map((v) => attribute.options.find((o) => o.value === v)?.label).filter(Boolean);
276
+ return labels.join(", ");
277
+ }
278
+ return value.join(", ");
279
+ }
280
+ function formatRating(value, attribute) {
281
+ if (typeof value !== "number") return String(value);
282
+ const max = attribute.max ?? 5;
283
+ return `${value}/${max}`;
284
+ }
285
+ function formatAttributeValue(value, attribute) {
286
+ if (value === null || value === void 0 || value === "") {
287
+ return EMPTY_VALUE_PLACEHOLDER;
288
+ }
289
+ switch (attribute.type) {
290
+ case "text":
291
+ case "textarea":
292
+ return formatText(value);
293
+ case "checkbox":
294
+ return formatCheckbox(value);
295
+ case "number":
296
+ return formatNumber(value, attribute);
297
+ case "currency":
298
+ return formatCurrency(value, attribute);
299
+ case "date":
300
+ return formatDate(value);
301
+ case "timestamp":
302
+ return formatTimestamp(value);
303
+ case "phone":
304
+ return formatPhone(value);
305
+ case "location":
306
+ return formatLocation(value, attribute);
307
+ case "select":
308
+ case "status":
309
+ return formatSelect(value, attribute);
310
+ case "multiselect":
311
+ return formatMultiselect(value, attribute);
312
+ case "rating":
313
+ return formatRating(value, attribute);
314
+ // Unsupported types - return value as-is or placeholder
315
+ case "file":
316
+ case "user":
317
+ case "relation":
318
+ if (Array.isArray(value)) {
319
+ return value.join(", ");
320
+ }
321
+ return String(value);
322
+ default: {
323
+ if (Array.isArray(value)) {
324
+ return value.join(", ");
325
+ }
326
+ return String(value);
327
+ }
328
+ }
329
+ }
330
+
168
331
  // src/runtime/template.ts
169
332
  var pipes = {
170
333
  /** Convert to uppercase */
@@ -216,6 +379,24 @@ function extractAttributeNames(template) {
216
379
  }
217
380
  return names;
218
381
  }
382
+ function hasOptions(attr) {
383
+ return "options" in attr && Array.isArray(attr.options) && attr.options.length > 0;
384
+ }
385
+ function enrichValuesWithSelectLabels(values, attributes) {
386
+ const enriched = { ...values };
387
+ for (const attr of attributes) {
388
+ const value = values[attr.name];
389
+ if (value == null) continue;
390
+ const isSelectLike = attr.type === "select" || attr.type === "status" || attr.type === "multiselect";
391
+ if (!(isSelectLike && hasOptions(attr))) continue;
392
+ if (attr.type === "multiselect" && Array.isArray(value) && value.length === 0) continue;
393
+ const formatted = formatAttributeValue(value, attr);
394
+ if (formatted && formatted !== EMPTY_VALUE_PLACEHOLDER) {
395
+ enriched[attr.name] = formatted;
396
+ }
397
+ }
398
+ return enriched;
399
+ }
219
400
 
220
401
  // src/runtime/mock-adapter.ts
221
402
  function createMockObjectsRepository(stores) {
@@ -692,14 +873,31 @@ function createMockObjectRecordsRepository(stores) {
692
873
  (options.offset ?? 0) + options.limit
693
874
  );
694
875
  }
876
+ const attributesByObjectId = /* @__PURE__ */ new Map();
877
+ for (const attr of stores.attributes.values()) {
878
+ if (!attributesByObjectId.has(attr.objectId)) {
879
+ attributesByObjectId.set(attr.objectId, []);
880
+ }
881
+ attributesByObjectId.get(attr.objectId)?.push(attr);
882
+ }
695
883
  const results = matchingRecords.map((r) => {
696
884
  const obj = objectsMap.get(r.objectId);
697
885
  const labelExpression = obj?.labelExpression ?? "{{ name }}";
886
+ const dbAttrs = attributesByObjectId.get(r.objectId) ?? [];
887
+ const attrs = dbAttrs.map((a) => ({
888
+ ...a.config,
889
+ id: a.id,
890
+ name: a.name,
891
+ type: a.type,
892
+ label: a.config.label ?? a.name,
893
+ required: a.config.required ?? false
894
+ }));
895
+ const enrichedValues = enrichValuesWithSelectLabels(r.values, attrs);
698
896
  return {
699
897
  objectId: r.objectId,
700
898
  objectName: obj?.name ?? "unknown",
701
899
  objectLabel: obj?.label ?? "Unknown",
702
- label: renderLabelExpression(labelExpression, r.values),
900
+ label: renderLabelExpression(labelExpression, enrichedValues),
703
901
  recordId: r.id,
704
902
  values: r.values,
705
903
  completionStatus: r.completionStatus,
@@ -1116,6 +1314,10 @@ var AuditService = class {
1116
1314
  this.options = options;
1117
1315
  this.buffer = [];
1118
1316
  this.flushTimer = null;
1317
+ /** Prevents concurrent flush operations */
1318
+ this.isFlushing = false;
1319
+ /** Pending flush promise to allow waiting on concurrent flush */
1320
+ this.flushPromise = null;
1119
1321
  if (options?.async && options.flushIntervalMs) {
1120
1322
  this.startFlushTimer();
1121
1323
  }
@@ -1268,14 +1470,23 @@ var AuditService = class {
1268
1470
  // ============================================================================
1269
1471
  /**
1270
1472
  * Flush buffered logs to the database
1473
+ * Protected against concurrent flush calls
1271
1474
  */
1272
1475
  async flush() {
1476
+ if (this.isFlushing && this.flushPromise) {
1477
+ return this.flushPromise;
1478
+ }
1273
1479
  if (this.buffer.length === 0 || !this.adapter.audit) {
1274
1480
  return;
1275
1481
  }
1482
+ this.isFlushing = true;
1276
1483
  const entries = [...this.buffer];
1277
1484
  this.buffer = [];
1278
- await this.adapter.audit.createMany(entries);
1485
+ this.flushPromise = this.adapter.audit.createMany(entries).finally(() => {
1486
+ this.isFlushing = false;
1487
+ this.flushPromise = null;
1488
+ });
1489
+ return this.flushPromise;
1279
1490
  }
1280
1491
  /**
1281
1492
  * Clean up resources (stop timer, flush remaining logs)
@@ -1297,6 +1508,12 @@ var AuditService = class {
1297
1508
  if (!this.adapter.audit) {
1298
1509
  return;
1299
1510
  }
1511
+ if (entry.actorId && !entry.actorEmail && entry.actorType === "user") {
1512
+ const profile = await this.adapter.userProfiles.findById(entry.actorId);
1513
+ if (profile) {
1514
+ entry.actorEmail = profile.email;
1515
+ }
1516
+ }
1300
1517
  if (this.options?.async) {
1301
1518
  this.buffer.push(entry);
1302
1519
  const batchSize = this.options.batchSize ?? 10;
@@ -1534,6 +1751,114 @@ var FileService = class {
1534
1751
  }
1535
1752
  };
1536
1753
 
1754
+ // src/exceptions.ts
1755
+ var SchemaErrorCode = {
1756
+ // Generic
1757
+ UNKNOWN: "SCHEMA_UNKNOWN_ERROR",
1758
+ // Not Found
1759
+ OBJECT_NOT_FOUND: "SCHEMA_OBJECT_NOT_FOUND",
1760
+ ATTRIBUTE_NOT_FOUND: "SCHEMA_ATTRIBUTE_NOT_FOUND",
1761
+ RECORD_NOT_FOUND: "SCHEMA_RECORD_NOT_FOUND",
1762
+ USER_PROFILE_NOT_FOUND: "SCHEMA_USER_PROFILE_NOT_FOUND",
1763
+ FILE_NOT_FOUND: "SCHEMA_FILE_NOT_FOUND",
1764
+ ROLE_NOT_FOUND: "SCHEMA_ROLE_NOT_FOUND",
1765
+ // Validation
1766
+ VALIDATION_FAILED: "SCHEMA_VALIDATION_FAILED",
1767
+ INVALID_ATTRIBUTE_NAME: "SCHEMA_INVALID_ATTRIBUTE_NAME",
1768
+ INVALID_OBJECT_NAME: "SCHEMA_INVALID_OBJECT_NAME",
1769
+ // Protected Resources
1770
+ PROTECTED_OBJECT: "SCHEMA_PROTECTED_OBJECT",
1771
+ PROTECTED_ATTRIBUTE: "SCHEMA_PROTECTED_ATTRIBUTE",
1772
+ PROTECTED_ROLE: "SCHEMA_PROTECTED_ROLE",
1773
+ // Permissions
1774
+ FORBIDDEN: "SCHEMA_FORBIDDEN",
1775
+ // Sync
1776
+ SYNC_FAILED: "SCHEMA_SYNC_FAILED",
1777
+ NOT_SYSTEM_OBJECT: "SCHEMA_NOT_SYSTEM_OBJECT",
1778
+ // Duplicates
1779
+ DUPLICATE_OBJECT: "SCHEMA_DUPLICATE_OBJECT",
1780
+ DUPLICATE_ATTRIBUTE: "SCHEMA_DUPLICATE_ATTRIBUTE"
1781
+ };
1782
+ var SchemaError = class extends Error {
1783
+ constructor(message, code = SchemaErrorCode.UNKNOWN, details) {
1784
+ super(message);
1785
+ this.name = "SchemaError";
1786
+ this.code = code;
1787
+ this.details = details;
1788
+ Object.setPrototypeOf(this, new.target.prototype);
1789
+ }
1790
+ toJSON() {
1791
+ return {
1792
+ name: this.name,
1793
+ code: this.code,
1794
+ message: this.message,
1795
+ details: this.details
1796
+ };
1797
+ }
1798
+ };
1799
+ var NotFoundError = class extends SchemaError {
1800
+ constructor(resourceType, resourceId, code = SchemaErrorCode.RECORD_NOT_FOUND) {
1801
+ super(`${resourceType} with id "${resourceId}" not found`, code, {
1802
+ resourceType,
1803
+ resourceId
1804
+ });
1805
+ this.name = "NotFoundError";
1806
+ this.resourceType = resourceType;
1807
+ this.resourceId = resourceId;
1808
+ }
1809
+ };
1810
+ var RecordNotFoundError = class extends NotFoundError {
1811
+ constructor(recordId) {
1812
+ super("Record", recordId, SchemaErrorCode.RECORD_NOT_FOUND);
1813
+ this.name = "RecordNotFoundError";
1814
+ }
1815
+ };
1816
+ var ValidationError = class _ValidationError extends SchemaError {
1817
+ constructor(message, errors) {
1818
+ super(message, SchemaErrorCode.VALIDATION_FAILED, { errors });
1819
+ this.name = "ValidationError";
1820
+ this.errors = errors;
1821
+ }
1822
+ /**
1823
+ * Create a validation error from Zod-style errors
1824
+ */
1825
+ static fromZodErrors(errors) {
1826
+ const details = errors.map((err) => ({
1827
+ path: err.path.map(String),
1828
+ message: err.message
1829
+ }));
1830
+ const message = `Validation failed: ${details.map((d) => `${d.path.join(".")}: ${d.message}`).join(", ")}`;
1831
+ return new _ValidationError(message, details);
1832
+ }
1833
+ };
1834
+ var ProtectedResourceError = class extends SchemaError {
1835
+ constructor(resourceType, resourceName, operation) {
1836
+ const code = resourceType === "object" ? SchemaErrorCode.PROTECTED_OBJECT : SchemaErrorCode.PROTECTED_ATTRIBUTE;
1837
+ super(`Cannot ${operation} system ${resourceType} "${resourceName}"`, code, {
1838
+ resourceType,
1839
+ resourceName,
1840
+ operation
1841
+ });
1842
+ this.name = "ProtectedResourceError";
1843
+ this.resourceType = resourceType;
1844
+ this.resourceName = resourceName;
1845
+ this.operation = operation;
1846
+ }
1847
+ };
1848
+ var ForbiddenError = class extends SchemaError {
1849
+ constructor(objectName, action, userId) {
1850
+ super(`No ${action} permission on object "${objectName}"`, SchemaErrorCode.FORBIDDEN, {
1851
+ objectName,
1852
+ action,
1853
+ userId
1854
+ });
1855
+ this.name = "ForbiddenError";
1856
+ this.objectName = objectName;
1857
+ this.action = action;
1858
+ this.userId = userId;
1859
+ }
1860
+ };
1861
+
1537
1862
  // src/runtime/services/flow.service.ts
1538
1863
  var FlowService = class {
1539
1864
  constructor(adapter, systemFlows = []) {
@@ -1594,16 +1919,26 @@ var FlowService = class {
1594
1919
  */
1595
1920
  async createFlow(input, tenantId) {
1596
1921
  if (!this.adapter.flows) {
1597
- throw new Error("Flows feature is not enabled. Database adapter does not support flows.");
1922
+ throw new SchemaError(
1923
+ "Flows feature is not enabled. Database adapter does not support flows.",
1924
+ SchemaErrorCode.UNKNOWN,
1925
+ { feature: "flows" }
1926
+ );
1598
1927
  }
1599
1928
  this.validateFlowName(input.name);
1600
1929
  const existing = await this.adapter.flows.findByName(tenantId, input.name);
1601
1930
  if (existing) {
1602
- throw new Error(`Flow "${input.name}" already exists`);
1931
+ throw new SchemaError(
1932
+ `Flow "${input.name}" already exists`,
1933
+ SchemaErrorCode.DUPLICATE_OBJECT,
1934
+ { flowName: input.name }
1935
+ );
1603
1936
  }
1604
1937
  if (this.systemFlows.has(input.name)) {
1605
- throw new Error(
1606
- `Cannot create flow "${input.name}": a system flow with this name already exists`
1938
+ throw new SchemaError(
1939
+ `Cannot create flow "${input.name}": a system flow with this name already exists`,
1940
+ SchemaErrorCode.DUPLICATE_OBJECT,
1941
+ { flowName: input.name, system: true }
1607
1942
  );
1608
1943
  }
1609
1944
  this.validateFlowStructure(input);
@@ -1628,14 +1963,16 @@ var FlowService = class {
1628
1963
  */
1629
1964
  async updateFlow(flowId, input) {
1630
1965
  if (!this.adapter.flows) {
1631
- throw new Error("Flows feature is not enabled.");
1966
+ throw new SchemaError("Flows feature is not enabled.", SchemaErrorCode.UNKNOWN, {
1967
+ feature: "flows"
1968
+ });
1632
1969
  }
1633
1970
  const dbFlow = await this.adapter.flows.findById(flowId);
1634
1971
  if (!dbFlow) {
1635
- throw new Error(`Flow with id "${flowId}" not found`);
1972
+ throw new NotFoundError("Flow", flowId);
1636
1973
  }
1637
1974
  if (dbFlow.system) {
1638
- throw new Error("Cannot modify system flows. System flows are protected.");
1975
+ throw new ProtectedResourceError("object", dbFlow.name, "modify");
1639
1976
  }
1640
1977
  if (input.slots || input.pages || input.relations) {
1641
1978
  this.validateFlowStructure({
@@ -1662,14 +1999,16 @@ var FlowService = class {
1662
1999
  */
1663
2000
  async publishFlow(flowId) {
1664
2001
  if (!this.adapter.flows) {
1665
- throw new Error("Flows feature is not enabled.");
2002
+ throw new SchemaError("Flows feature is not enabled.", SchemaErrorCode.UNKNOWN, {
2003
+ feature: "flows"
2004
+ });
1666
2005
  }
1667
2006
  const dbFlow = await this.adapter.flows.findById(flowId);
1668
2007
  if (!dbFlow) {
1669
- throw new Error(`Flow with id "${flowId}" not found`);
2008
+ throw new NotFoundError("Flow", flowId);
1670
2009
  }
1671
2010
  if (dbFlow.system) {
1672
- throw new Error("Cannot publish system flows. They are always published.");
2011
+ throw new ProtectedResourceError("object", dbFlow.name, "modify");
1673
2012
  }
1674
2013
  if (dbFlow.status === "published") {
1675
2014
  return this.convertDBFlowToDefinition(dbFlow);
@@ -1692,14 +2031,16 @@ var FlowService = class {
1692
2031
  */
1693
2032
  async archiveFlow(flowId) {
1694
2033
  if (!this.adapter.flows) {
1695
- throw new Error("Flows feature is not enabled.");
2034
+ throw new SchemaError("Flows feature is not enabled.", SchemaErrorCode.UNKNOWN, {
2035
+ feature: "flows"
2036
+ });
1696
2037
  }
1697
2038
  const dbFlow = await this.adapter.flows.findById(flowId);
1698
2039
  if (!dbFlow) {
1699
- throw new Error(`Flow with id "${flowId}" not found`);
2040
+ throw new NotFoundError("Flow", flowId);
1700
2041
  }
1701
2042
  if (dbFlow.system) {
1702
- throw new Error("Cannot archive system flows.");
2043
+ throw new ProtectedResourceError("object", dbFlow.name, "modify");
1703
2044
  }
1704
2045
  const updated = await this.adapter.flows.update(flowId, {
1705
2046
  status: "archived"
@@ -1711,14 +2052,16 @@ var FlowService = class {
1711
2052
  */
1712
2053
  async deleteFlow(flowId) {
1713
2054
  if (!this.adapter.flows) {
1714
- throw new Error("Flows feature is not enabled.");
2055
+ throw new SchemaError("Flows feature is not enabled.", SchemaErrorCode.UNKNOWN, {
2056
+ feature: "flows"
2057
+ });
1715
2058
  }
1716
2059
  const dbFlow = await this.adapter.flows.findById(flowId);
1717
2060
  if (!dbFlow) {
1718
- throw new Error(`Flow with id "${flowId}" not found`);
2061
+ throw new NotFoundError("Flow", flowId);
1719
2062
  }
1720
2063
  if (dbFlow.system) {
1721
- throw new Error("Cannot delete system flows. System flows are protected.");
2064
+ throw new ProtectedResourceError("object", dbFlow.name, "delete");
1722
2065
  }
1723
2066
  await this.adapter.flows.delete(flowId);
1724
2067
  }
@@ -1730,16 +2073,23 @@ var FlowService = class {
1730
2073
  */
1731
2074
  validateFlowName(name) {
1732
2075
  if (!name || name.length === 0) {
1733
- throw new Error("Flow name cannot be empty");
2076
+ throw new ValidationError("Flow name cannot be empty", [
2077
+ { path: ["name"], message: "Flow name cannot be empty" }
2078
+ ]);
1734
2079
  }
1735
2080
  if (name.length > 63) {
1736
- throw new Error("Flow name is too long (max 63 characters)");
2081
+ throw new ValidationError("Flow name is too long", [
2082
+ { path: ["name"], message: "Flow name is too long (max 63 characters)" }
2083
+ ]);
1737
2084
  }
1738
2085
  const kebabCaseRegex = /^[a-z][a-z0-9-]*$/;
1739
2086
  if (!kebabCaseRegex.test(name)) {
1740
- throw new Error(
1741
- "Invalid flow name format. Name must be in kebab-case (e.g., 'couple-creation', 'new-contact')"
1742
- );
2087
+ throw new ValidationError("Invalid flow name format", [
2088
+ {
2089
+ path: ["name"],
2090
+ message: "Flow name must be in kebab-case (e.g., 'couple-creation', 'new-contact')"
2091
+ }
2092
+ ]);
1743
2093
  }
1744
2094
  }
1745
2095
  /**
@@ -1747,33 +2097,59 @@ var FlowService = class {
1747
2097
  */
1748
2098
  validateFlowStructure(input) {
1749
2099
  if (!input.slots || input.slots.length === 0) {
1750
- throw new Error("Flow must have at least one slot");
2100
+ throw new ValidationError("Flow must have at least one slot", [
2101
+ { path: ["slots"], message: "Flow must have at least one slot" }
2102
+ ]);
1751
2103
  }
1752
2104
  if (!input.pages || input.pages.length === 0) {
1753
- throw new Error("Flow must have at least one page");
2105
+ throw new ValidationError("Flow must have at least one page", [
2106
+ { path: ["pages"], message: "Flow must have at least one page" }
2107
+ ]);
1754
2108
  }
1755
2109
  const slotIds = new Set(input.slots.map((s) => s.id));
1756
2110
  if (slotIds.size !== input.slots.length) {
1757
- throw new Error("Duplicate slot IDs detected");
2111
+ throw new ValidationError("Duplicate slot IDs detected", [
2112
+ { path: ["slots"], message: "Duplicate slot IDs detected" }
2113
+ ]);
1758
2114
  }
1759
2115
  for (const page of input.pages) {
1760
2116
  for (const row of page.rows) {
1761
2117
  for (const field of row.fields) {
1762
2118
  if (!slotIds.has(field.slotId)) {
1763
- throw new Error(`Field "${field.id}" references unknown slot "${field.slotId}"`);
2119
+ throw new ValidationError("Field references unknown slot", [
2120
+ {
2121
+ path: ["pages", page.id, "rows", row.id, "fields", field.id],
2122
+ message: `Field "${field.id}" references unknown slot "${field.slotId}"`
2123
+ }
2124
+ ]);
1764
2125
  }
1765
2126
  }
1766
2127
  }
1767
2128
  }
1768
2129
  for (const relation of input.relations) {
1769
2130
  if (!slotIds.has(relation.sourceSlotId)) {
1770
- throw new Error(`Relation references unknown source slot "${relation.sourceSlotId}"`);
2131
+ throw new ValidationError("Relation references unknown source slot", [
2132
+ {
2133
+ path: ["relations", relation.id, "sourceSlotId"],
2134
+ message: `Relation references unknown source slot "${relation.sourceSlotId}"`
2135
+ }
2136
+ ]);
1771
2137
  }
1772
2138
  if (!slotIds.has(relation.targetSlotId)) {
1773
- throw new Error(`Relation references unknown target slot "${relation.targetSlotId}"`);
2139
+ throw new ValidationError("Relation references unknown target slot", [
2140
+ {
2141
+ path: ["relations", relation.id, "targetSlotId"],
2142
+ message: `Relation references unknown target slot "${relation.targetSlotId}"`
2143
+ }
2144
+ ]);
1774
2145
  }
1775
2146
  if (relation.sourceSlotId === relation.targetSlotId) {
1776
- throw new Error(`Relation cannot link a slot to itself: "${relation.sourceSlotId}"`);
2147
+ throw new ValidationError("Relation cannot link a slot to itself", [
2148
+ {
2149
+ path: ["relations", relation.id],
2150
+ message: `Relation cannot link a slot to itself: "${relation.sourceSlotId}"`
2151
+ }
2152
+ ]);
1777
2153
  }
1778
2154
  }
1779
2155
  }
@@ -1917,8 +2293,8 @@ var GlobalSearchService = class {
1917
2293
  };
1918
2294
 
1919
2295
  // src/builders/attribute-validators.ts
1920
- var import_constants = require("@stndrds/constants");
1921
- var VALID_ICONS = new Set(import_constants.ICONS);
2296
+ var import_constants2 = require("@stndrds/constants");
2297
+ var VALID_ICONS = new Set(import_constants2.ICONS);
1922
2298
 
1923
2299
  // src/builders/flow-builder.ts
1924
2300
  var import_zod = require("zod");
@@ -2132,6 +2508,13 @@ var optionSchema = import_zod4.z.object({
2132
2508
  description: import_zod4.z.string().optional(),
2133
2509
  group: import_zod4.z.enum(["idle", "in_progress", "finished"]).optional()
2134
2510
  });
2511
+ var optionsArraySchema = import_zod4.z.array(optionSchema).min(1).refine(
2512
+ (options) => {
2513
+ const values = options.map((o) => o.value);
2514
+ return new Set(values).size === values.length;
2515
+ },
2516
+ { message: "Duplicate option values are not allowed" }
2517
+ );
2135
2518
  var relationTargetSchema = import_zod4.z.object({
2136
2519
  object: import_zod4.z.string().min(1),
2137
2520
  displayTemplate: import_zod4.z.string().optional(),
@@ -2192,7 +2575,7 @@ var currencyConfigSchema = baseConfigSchema.extend({
2192
2575
  allowedCurrencies: import_zod4.z.array(import_zod4.z.string().length(3)).optional()
2193
2576
  });
2194
2577
  var statusConfigSchema = baseConfigSchema.extend({
2195
- options: import_zod4.z.array(optionSchema).min(1)
2578
+ options: optionsArraySchema
2196
2579
  });
2197
2580
  var locationConfigSchema = baseConfigSchema.extend({
2198
2581
  granularity: import_zod4.z.enum(["full", "address", "city", "state", "country", "coordinates"]),
@@ -2206,10 +2589,10 @@ var timestampConfigSchema = baseConfigSchema.extend({
2206
2589
  autoUpdate: import_zod4.z.boolean().optional()
2207
2590
  });
2208
2591
  var selectConfigSchema = baseConfigSchema.extend({
2209
- options: import_zod4.z.array(optionSchema).min(1)
2592
+ options: optionsArraySchema
2210
2593
  });
2211
2594
  var multiselectConfigSchema = baseConfigSchema.extend({
2212
- options: import_zod4.z.array(optionSchema).min(1)
2595
+ options: optionsArraySchema
2213
2596
  });
2214
2597
  var fileConfigSchema = baseConfigSchema.extend({
2215
2598
  maxFiles: import_zod4.z.number().int().min(1).optional(),
@@ -2871,14 +3254,15 @@ var ObjectSchemaService = class {
2871
3254
  });
2872
3255
  }
2873
3256
  }
3257
+ const dbAttributes = await this.adapter.attributes.findByObjectId(objectId);
3258
+ const attributes = dbAttributes.map((attr) => this.convertDBAttributeToAttribute(attr));
2874
3259
  if (updates.labelExpression !== void 0 && updates.labelExpression !== oldValues.labelExpression) {
2875
3260
  const newExpression = updates.labelExpression;
2876
- await this.adapter.objectRecords.batchRefreshLabels(
2877
- objectId,
2878
- (values) => renderLabelExpression(newExpression, values)
2879
- );
3261
+ await this.adapter.objectRecords.batchRefreshLabels(objectId, (values) => {
3262
+ const enrichedValues = enrichValuesWithSelectLabels(values, attributes);
3263
+ return renderLabelExpression(newExpression, enrichedValues);
3264
+ });
2880
3265
  }
2881
- const dbAttributes = await this.adapter.attributes.findByObjectId(objectId);
2882
3266
  return this.convertDBObjectToDefinition(updatedDbObject, dbAttributes);
2883
3267
  }
2884
3268
  /**
@@ -3191,71 +3575,14 @@ var ObjectSchemaService = class {
3191
3575
  }
3192
3576
  };
3193
3577
 
3194
- // src/exceptions.ts
3195
- var SchemaErrorCode = {
3196
- // Generic
3197
- UNKNOWN: "SCHEMA_UNKNOWN_ERROR",
3198
- // Not Found
3199
- OBJECT_NOT_FOUND: "SCHEMA_OBJECT_NOT_FOUND",
3200
- ATTRIBUTE_NOT_FOUND: "SCHEMA_ATTRIBUTE_NOT_FOUND",
3201
- RECORD_NOT_FOUND: "SCHEMA_RECORD_NOT_FOUND",
3202
- USER_PROFILE_NOT_FOUND: "SCHEMA_USER_PROFILE_NOT_FOUND",
3203
- FILE_NOT_FOUND: "SCHEMA_FILE_NOT_FOUND",
3204
- ROLE_NOT_FOUND: "SCHEMA_ROLE_NOT_FOUND",
3205
- // Validation
3206
- VALIDATION_FAILED: "SCHEMA_VALIDATION_FAILED",
3207
- INVALID_ATTRIBUTE_NAME: "SCHEMA_INVALID_ATTRIBUTE_NAME",
3208
- INVALID_OBJECT_NAME: "SCHEMA_INVALID_OBJECT_NAME",
3209
- // Protected Resources
3210
- PROTECTED_OBJECT: "SCHEMA_PROTECTED_OBJECT",
3211
- PROTECTED_ATTRIBUTE: "SCHEMA_PROTECTED_ATTRIBUTE",
3212
- PROTECTED_ROLE: "SCHEMA_PROTECTED_ROLE",
3213
- // Permissions
3214
- FORBIDDEN: "SCHEMA_FORBIDDEN",
3215
- // Sync
3216
- SYNC_FAILED: "SCHEMA_SYNC_FAILED",
3217
- NOT_SYSTEM_OBJECT: "SCHEMA_NOT_SYSTEM_OBJECT",
3218
- // Duplicates
3219
- DUPLICATE_OBJECT: "SCHEMA_DUPLICATE_OBJECT",
3220
- DUPLICATE_ATTRIBUTE: "SCHEMA_DUPLICATE_ATTRIBUTE"
3221
- };
3222
- var SchemaError = class extends Error {
3223
- constructor(message, code = SchemaErrorCode.UNKNOWN, details) {
3224
- super(message);
3225
- this.name = "SchemaError";
3226
- this.code = code;
3227
- this.details = details;
3228
- Object.setPrototypeOf(this, new.target.prototype);
3229
- }
3230
- toJSON() {
3231
- return {
3232
- name: this.name,
3233
- code: this.code,
3234
- message: this.message,
3235
- details: this.details
3236
- };
3237
- }
3238
- };
3239
- var ForbiddenError = class extends SchemaError {
3240
- constructor(objectName, action, userId) {
3241
- super(`No ${action} permission on object "${objectName}"`, SchemaErrorCode.FORBIDDEN, {
3242
- objectName,
3243
- action,
3244
- userId
3245
- });
3246
- this.name = "ForbiddenError";
3247
- this.objectName = objectName;
3248
- this.action = action;
3249
- this.userId = userId;
3250
- }
3251
- };
3252
-
3253
3578
  // src/runtime/services/permission.service.ts
3254
3579
  var PermissionService = class {
3255
3580
  constructor(adapter, tenantId, options) {
3256
3581
  this.adapter = adapter;
3257
3582
  this.tenantId = tenantId;
3258
3583
  this.cache = /* @__PURE__ */ new Map();
3584
+ /** Track pending permission fetches to prevent duplicate concurrent requests */
3585
+ this.pendingFetches = /* @__PURE__ */ new Map();
3259
3586
  if (!adapter.permissions) {
3260
3587
  throw new Error(
3261
3588
  "PermissionService requires a DatabaseAdapter with permissions repository. Make sure your adapter implements the permissions property."
@@ -3404,6 +3731,23 @@ var PermissionService = class {
3404
3731
  if (cached && cached.expiresAt > Date.now()) {
3405
3732
  return cached.permissions;
3406
3733
  }
3734
+ const pendingFetch = this.pendingFetches.get(cacheKey);
3735
+ if (pendingFetch) {
3736
+ return pendingFetch;
3737
+ }
3738
+ const fetchPromise = this.fetchAndCachePermissions(userProfileId, cacheKey);
3739
+ this.pendingFetches.set(cacheKey, fetchPromise);
3740
+ try {
3741
+ return await fetchPromise;
3742
+ } finally {
3743
+ this.pendingFetches.delete(cacheKey);
3744
+ }
3745
+ }
3746
+ /**
3747
+ * Fetch permissions from database and cache the result
3748
+ * @internal
3749
+ */
3750
+ async fetchAndCachePermissions(userProfileId, cacheKey) {
3407
3751
  const permissions = await this.permissionsRepo.getEffectivePermissions(
3408
3752
  userProfileId,
3409
3753
  this.tenantId
@@ -3932,8 +4276,11 @@ var RelationService = class {
3932
4276
  async validateRelationsOrThrow(schema, data) {
3933
4277
  const result = await this.validateRelations(schema, data);
3934
4278
  if (!result.valid) {
3935
- const messages = result.errors.map((e) => `${e.attribute}: ${e.message}`).join("; ");
3936
- throw new Error(`Relation validation failed: ${messages}`);
4279
+ const errors = result.errors.map((e) => ({
4280
+ path: [e.attribute],
4281
+ message: e.message
4282
+ }));
4283
+ throw new ValidationError("Relation validation failed", errors);
3937
4284
  }
3938
4285
  }
3939
4286
  // ============================================================================
@@ -3981,7 +4328,8 @@ var RelationService = class {
3981
4328
  totalCount += result.total;
3982
4329
  for (const record of result.records) {
3983
4330
  const template = target.displayTemplate || objectSchema.labelExpression;
3984
- const label = renderLabelExpression(template, record.values);
4331
+ const enrichedValues = enrichValuesWithSelectLabels(record.values, objectSchema.attributes);
4332
+ const label = renderLabelExpression(template, enrichedValues);
3985
4333
  allOptions.push({
3986
4334
  id: record.id,
3987
4335
  objectId: objectSchema.id,
@@ -4045,7 +4393,8 @@ var RelationService = class {
4045
4393
  }
4046
4394
  }
4047
4395
  for (const record of objectRecords) {
4048
- const label = renderLabelExpression(template, record.values);
4396
+ const enrichedValues = enrichValuesWithSelectLabels(record.values, objectSchema.attributes);
4397
+ const label = renderLabelExpression(template, enrichedValues);
4049
4398
  resolved.push({
4050
4399
  id: record.id,
4051
4400
  objectId: record.objectId,
@@ -4066,10 +4415,14 @@ var RelationService = class {
4066
4415
  if (!attribute || attribute.type !== "relation") {
4067
4416
  return null;
4068
4417
  }
4069
- return {
4418
+ const merged = {
4070
4419
  ...attribute.config,
4071
4420
  ...attribute
4072
4421
  };
4422
+ if (!("targets" in merged && Array.isArray(merged.targets) && "cardinality" in merged)) {
4423
+ return null;
4424
+ }
4425
+ return merged;
4073
4426
  }
4074
4427
  };
4075
4428
 
@@ -4082,7 +4435,7 @@ var RecordService = class {
4082
4435
  this.relationService = new RelationService(adapter, registry);
4083
4436
  this.hookRegistry = options?.hookRegistry ?? new NoopHookRegistry();
4084
4437
  this.permissionService = options?.permissionService;
4085
- this.auditService = options?.auditService;
4438
+ this.auditService = options?.auditService ?? (adapter.audit ? new AuditService(adapter, tenantId) : void 0);
4086
4439
  this.userId = options?.userId;
4087
4440
  this.userEmail = options?.userEmail;
4088
4441
  }
@@ -4149,21 +4502,23 @@ var RecordService = class {
4149
4502
  /**
4150
4503
  * Compute display label from schema expression
4151
4504
  * Automatically resolves relation attribute values to their labels
4505
+ * and select/multiselect values to their option labels
4152
4506
  * @internal
4153
4507
  */
4154
4508
  async computeLabel(schema, values) {
4155
4509
  const attrNames = extractAttributeNames(schema.labelExpression);
4510
+ let enrichedValues = enrichValuesWithSelectLabels(values, schema.attributes);
4156
4511
  const relationAttrs = schema.attributes.filter(
4157
4512
  (attr) => attr.type === "relation" && attrNames.includes(attr.name)
4158
4513
  );
4159
4514
  if (relationAttrs.length === 0) {
4160
- return renderLabelExpression(schema.labelExpression, values);
4515
+ return renderLabelExpression(schema.labelExpression, enrichedValues);
4161
4516
  }
4162
4517
  const resolvedMap = await this.resolveRelationLabels(relationAttrs, values);
4163
4518
  if (resolvedMap.size === 0) {
4164
- return renderLabelExpression(schema.labelExpression, values);
4519
+ return renderLabelExpression(schema.labelExpression, enrichedValues);
4165
4520
  }
4166
- const enrichedValues = { ...values };
4521
+ enrichedValues = { ...enrichedValues };
4167
4522
  for (const attr of relationAttrs) {
4168
4523
  const val = values[attr.name];
4169
4524
  const ids = this.extractRelationIds(val);
@@ -4276,7 +4631,7 @@ var RecordService = class {
4276
4631
  async getRecordOrThrow(recordId) {
4277
4632
  const record = await this.getRecord(recordId);
4278
4633
  if (!record) {
4279
- throw new Error(`Record with id "${recordId}" not found`);
4634
+ throw new RecordNotFoundError(recordId);
4280
4635
  }
4281
4636
  return record;
4282
4637
  }
@@ -4454,7 +4809,7 @@ var RecordService = class {
4454
4809
  await this.checkPermission(schema.name, "delete");
4455
4810
  if (options?.checkSystem) {
4456
4811
  if (schema.system) {
4457
- throw new Error(`Cannot delete record of system object "${schema.label}"`);
4812
+ throw new ProtectedResourceError("object", schema.name, "delete");
4458
4813
  }
4459
4814
  }
4460
4815
  if (!options?.skipReferenceCheck) {
@@ -4503,7 +4858,11 @@ var RecordService = class {
4503
4858
  async restoreRecord(recordId, options) {
4504
4859
  const record = await this.getRecordOrThrow(recordId);
4505
4860
  if (!record.deletedAt) {
4506
- throw new Error(`Record "${recordId}" is not deleted`);
4861
+ throw new SchemaError(
4862
+ `Record "${recordId}" is not deleted`,
4863
+ SchemaErrorCode.VALIDATION_FAILED,
4864
+ { recordId, reason: "not_deleted" }
4865
+ );
4507
4866
  }
4508
4867
  const schema = await this.schemaService.getObjectSchema(record.objectId);
4509
4868
  await this.checkPermission(schema.name, "update");
@@ -4941,11 +5300,17 @@ var ViewService = class {
4941
5300
  this.validateViewName(input.name);
4942
5301
  const existing = await this.adapter.views.findByName(tenantId, input.objectName, input.name);
4943
5302
  if (existing) {
4944
- throw new Error(`View "${input.name}" already exists for object "${input.objectName}"`);
5303
+ throw new SchemaError(
5304
+ `View "${input.name}" already exists for object "${input.objectName}"`,
5305
+ SchemaErrorCode.DUPLICATE_ATTRIBUTE,
5306
+ { viewName: input.name, objectName: input.objectName }
5307
+ );
4945
5308
  }
4946
5309
  if (this.nativeViews.has(input.objectName, input.name)) {
4947
- throw new Error(
4948
- `Cannot create view "${input.name}": a system view with this name already exists`
5310
+ throw new SchemaError(
5311
+ `Cannot create view "${input.name}": a system view with this name already exists`,
5312
+ SchemaErrorCode.DUPLICATE_ATTRIBUTE,
5313
+ { viewName: input.name, objectName: input.objectName, system: true }
4949
5314
  );
4950
5315
  }
4951
5316
  const dbView = await this.adapter.views.create({
@@ -4973,10 +5338,10 @@ var ViewService = class {
4973
5338
  async updateView(viewId, input) {
4974
5339
  const dbView = await this.adapter.views.findById(viewId);
4975
5340
  if (!dbView) {
4976
- throw new Error(`View with id "${viewId}" not found`);
5341
+ throw new NotFoundError("View", viewId);
4977
5342
  }
4978
5343
  if (dbView.system) {
4979
- throw new Error("Cannot modify system views. System views are protected.");
5344
+ throw new ProtectedResourceError("attribute", dbView.name, "modify");
4980
5345
  }
4981
5346
  const updated = await this.adapter.views.update(viewId, {
4982
5347
  label: input.label,
@@ -4996,10 +5361,10 @@ var ViewService = class {
4996
5361
  async deleteView(viewId) {
4997
5362
  const dbView = await this.adapter.views.findById(viewId);
4998
5363
  if (!dbView) {
4999
- throw new Error(`View with id "${viewId}" not found`);
5364
+ throw new NotFoundError("View", viewId);
5000
5365
  }
5001
5366
  if (dbView.system) {
5002
- throw new Error("Cannot delete system views. System views are protected.");
5367
+ throw new ProtectedResourceError("attribute", dbView.name, "delete");
5003
5368
  }
5004
5369
  await this.adapter.views.delete(viewId);
5005
5370
  }
@@ -5013,7 +5378,7 @@ var ViewService = class {
5013
5378
  async setDefaultView(viewId, tenantId) {
5014
5379
  const dbView = await this.adapter.views.findById(viewId);
5015
5380
  if (!dbView) {
5016
- throw new Error(`View with id "${viewId}" not found`);
5381
+ throw new NotFoundError("View", viewId);
5017
5382
  }
5018
5383
  const currentViews = await this.adapter.views.findByObjectName(tenantId, dbView.objectName);
5019
5384
  for (const v of currentViews) {
@@ -5032,16 +5397,23 @@ var ViewService = class {
5032
5397
  */
5033
5398
  validateViewName(name) {
5034
5399
  if (!name || name.length === 0) {
5035
- throw new Error("View name cannot be empty");
5400
+ throw new ValidationError("View name cannot be empty", [
5401
+ { path: ["name"], message: "View name cannot be empty" }
5402
+ ]);
5036
5403
  }
5037
5404
  if (name.length > 63) {
5038
- throw new Error("View name is too long (max 63 characters)");
5405
+ throw new ValidationError("View name is too long", [
5406
+ { path: ["name"], message: "View name is too long (max 63 characters)" }
5407
+ ]);
5039
5408
  }
5040
5409
  const kebabCaseRegex = /^[a-z][a-z0-9-]*$/;
5041
5410
  if (!kebabCaseRegex.test(name)) {
5042
- throw new Error(
5043
- "Invalid view name format. Name must be in kebab-case (e.g., 'detail', 'list-view')"
5044
- );
5411
+ throw new ValidationError("Invalid view name format", [
5412
+ {
5413
+ path: ["name"],
5414
+ message: "View name must be in kebab-case (e.g., 'detail', 'list-view')"
5415
+ }
5416
+ ]);
5045
5417
  }
5046
5418
  }
5047
5419
  /**
@@ -5421,6 +5793,7 @@ var NoopGeocodingAdapter = class {
5421
5793
  ViewService,
5422
5794
  buildAuditChanges,
5423
5795
  createMockAdapter,
5796
+ enrichValuesWithSelectLabels,
5424
5797
  extractAttributeNames,
5425
5798
  getSyncPreview,
5426
5799
  getViewSyncPreview,