velocious 1.0.493 → 1.0.495

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 (34) hide show
  1. package/build/database/drivers/base.js +6 -0
  2. package/build/database/query/insert-base.js +1 -1
  3. package/build/frontend-models/resource-definition.js +3 -1
  4. package/build/src/database/drivers/base.d.ts +5 -0
  5. package/build/src/database/drivers/base.d.ts.map +1 -1
  6. package/build/src/database/drivers/base.js +6 -1
  7. package/build/src/database/query/insert-base.js +2 -2
  8. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  9. package/build/src/frontend-models/resource-definition.js +4 -2
  10. package/build/src/sync/server-sequence-allocator.d.ts +106 -0
  11. package/build/src/sync/server-sequence-allocator.d.ts.map +1 -0
  12. package/build/src/sync/server-sequence-allocator.js +220 -0
  13. package/build/src/sync/sync-attribute-normalizer.d.ts +105 -0
  14. package/build/src/sync/sync-attribute-normalizer.d.ts.map +1 -0
  15. package/build/src/sync/sync-attribute-normalizer.js +172 -0
  16. package/build/src/sync/sync-resource-base.d.ts +48 -0
  17. package/build/src/sync/sync-resource-base.d.ts.map +1 -1
  18. package/build/src/sync/sync-resource-base.js +89 -1
  19. package/build/src/testing/test-runner.d.ts +21 -0
  20. package/build/src/testing/test-runner.d.ts.map +1 -1
  21. package/build/src/testing/test-runner.js +78 -9
  22. package/build/sync/server-sequence-allocator.js +246 -0
  23. package/build/sync/sync-attribute-normalizer.js +186 -0
  24. package/build/sync/sync-resource-base.js +103 -0
  25. package/build/testing/test-runner.js +82 -8
  26. package/build/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +2 -2
  28. package/src/database/drivers/base.js +6 -0
  29. package/src/database/query/insert-base.js +1 -1
  30. package/src/frontend-models/resource-definition.js +3 -1
  31. package/src/sync/server-sequence-allocator.js +246 -0
  32. package/src/sync/sync-attribute-normalizer.js +186 -0
  33. package/src/sync/sync-resource-base.js +103 -0
  34. package/src/testing/test-runner.js +82 -8
@@ -3,7 +3,11 @@
3
3
  import {forcedNonBlankStringParam} from "typanic"
4
4
 
5
5
  import FrontendModelBaseResource from "../frontend-model-resource/base-resource.js"
6
+ import normalizeAttributesWithSchema from "./sync-attribute-normalizer.js"
6
7
  import SyncModelChangeFeedService from "./sync-model-change-feed-service.js"
8
+ import VelociousError from "../velocious-error.js"
9
+
10
+ const QUICK_SEARCH_COLUMN = "quickSearch"
7
11
 
8
12
  /**
9
13
  * Optional client-declared sync scope carried on a changes request.
@@ -22,6 +26,105 @@ import SyncModelChangeFeedService from "./sync-model-change-feed-service.js"
22
26
  * @augments {FrontendModelBaseResource<TModelClass>}
23
27
  */
24
28
  export default class SyncResourceBase extends FrontendModelBaseResource {
29
+ /**
30
+ * Declarative quick-search text columns. When declared, an index search on
31
+ * the pseudo-column `quickSearch` expands to an OR of LIKE conditions over
32
+ * these root-table columns instead of hitting the controller default.
33
+ * @type {string[] | null} */
34
+ static quickSearchColumns = null
35
+
36
+ /**
37
+ * Declarative writable-attribute schema consumed by
38
+ * {@link SyncResourceBase#normalizeWritableAttributes}, keyed by camelCase
39
+ * attribute name.
40
+ * @type {Record<string, import("./sync-attribute-normalizer.js").SyncAttributeSchemaEntry> | null} */
41
+ static writableAttributes = null
42
+
43
+ /**
44
+ * Applies frontend-model index searches, expanding declared quick searches.
45
+ * @param {object} args - Search args.
46
+ * @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceController} args.controller - Controller handling the query.
47
+ * @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceAnyQuery} args.query - Query instance.
48
+ * @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceSearch} args.search - Search params.
49
+ * @returns {void}
50
+ */
51
+ applyFrontendModelIndexSearch({controller, query, search}) {
52
+ if (this.applyQuickSearch({query, search})) return
53
+
54
+ super.applyFrontendModelIndexSearch({controller, query, search})
55
+ }
56
+
57
+ /**
58
+ * Expands a `quickSearch` pseudo-column search into an OR of LIKE conditions
59
+ * over the declared {@link SyncResourceBase.quickSearchColumns}.
60
+ * @param {object} args - Search args.
61
+ * @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceAnyQuery} args.query - Query to filter.
62
+ * @param {import("../frontend-model-resource/base-resource.js").FrontendModelResourceSearch} args.search - Search payload.
63
+ * @returns {boolean} Whether the search was handled as a quick search.
64
+ */
65
+ applyQuickSearch({query, search}) {
66
+ const quickSearchColumns = /** @type {typeof SyncResourceBase} */ (this.constructor).quickSearchColumns
67
+
68
+ if (!quickSearchColumns || quickSearchColumns.length === 0) return false
69
+ if (search.path.length > 0 || search.column !== QUICK_SEARCH_COLUMN) return false
70
+
71
+ if (search.operator !== "like") {
72
+ throw VelociousError.safe("Sync quick search must use the like operator.", {code: "sync-invalid-quick-search"})
73
+ }
74
+
75
+ if (typeof search.value !== "string") {
76
+ throw VelociousError.safe("Sync quick search must be a string.", {code: "sync-invalid-quick-search"})
77
+ }
78
+
79
+ const trimmedValue = search.value.trim()
80
+
81
+ if (!trimmedValue) return true
82
+
83
+ const tableSql = query.driver.quoteTable(query.getTableReferenceForJoin())
84
+ const likeValue = `%${trimmedValue}%`
85
+ const conditions = quickSearchColumns.map((columnName) => (
86
+ `${tableSql}.${query.driver.quoteColumn(columnName)} LIKE ${query.driver.quote(likeValue)}`
87
+ ))
88
+
89
+ query.where(`(${conditions.join(" OR ")})`)
90
+
91
+ return true
92
+ }
93
+
94
+ /**
95
+ * Normalizes incoming writable attributes through the declared
96
+ * {@link SyncResourceBase.writableAttributes} schema: camelCase and
97
+ * snake_case input keys are accepted, values are validated per type and the
98
+ * normalized values are written under snake_case column keys. Validation
99
+ * failures throw client-safe errors built by
100
+ * {@link SyncResourceBase#writableAttributeError}.
101
+ * @param {Record<string, ?>} attributes - Raw incoming attributes.
102
+ * @param {{unknownAttributes?: "error" | "ignore"}} [options] - Unknown input-key handling. Defaults to "error".
103
+ * @returns {Record<string, ?>} Normalized attributes keyed by column names.
104
+ */
105
+ normalizeWritableAttributes(attributes, options = {}) {
106
+ const schema = /** @type {typeof SyncResourceBase} */ (this.constructor).writableAttributes
107
+
108
+ if (!schema) throw new Error(`${this.constructor.name} must define static writableAttributes to use normalizeWritableAttributes`)
109
+
110
+ return normalizeAttributesWithSchema({
111
+ attributes,
112
+ errorFactory: (message, details) => this.writableAttributeError(message, details),
113
+ schema,
114
+ unknownAttributes: options.unknownAttributes
115
+ })
116
+ }
117
+
118
+ /**
119
+ * Builds the client-safe error thrown for a failed writable-attribute validation.
120
+ * @param {string} message - Human-readable validation message.
121
+ * @param {{cause?: Error, code: string}} details - Stable machine-readable code and optional cause.
122
+ * @returns {Error} Client-safe error.
123
+ */
124
+ writableAttributeError(message, {cause, code}) {
125
+ return VelociousError.safe(message, cause ? {cause, code} : {code})
126
+ }
127
+
25
128
  /**
26
129
  * Returns a stable change-feed page after app authorization.
27
130
  * @returns {Promise<Record<string, ?>>} Change-feed page result.
@@ -187,6 +187,8 @@ export default class TestRunner {
187
187
  this._testsCount = 0
188
188
  this._activeAfterAllScopes = []
189
189
  this._failedTestDetails = []
190
+ /** @type {{fullDescription: string, filePath: string, line: number} | null} */
191
+ this._lastTestContext = null
190
192
  }
191
193
 
192
194
  /**
@@ -638,16 +640,80 @@ export default class TestRunner {
638
640
  * Runs run.
639
641
  * @returns {Promise<void>} - Resolves when complete.
640
642
  */
643
+ /**
644
+ * Records an asynchronous crash (an unhandled promise rejection detached from
645
+ * any await, e.g. a `void connection.afterCommit(async () => broadcast(...))`
646
+ * frontend-model publish) as a real, visible, attributed test failure.
647
+ *
648
+ * Without this, such a rejection has no handler, so on modern Node the process
649
+ * is TERMINATED — the run ends with no reported failures and CI just sees a
650
+ * crashed/retried shard with an empty result (the recurring "silent
651
+ * test-runner death": invisible and impossible to diagnose). Turning it into a
652
+ * failure makes the run go red with something debuggable instead of vanishing.
653
+ * @param {"unhandledRejection"} kind - Async-crash kind.
654
+ * @param {unknown} reason - Rejection reason.
655
+ * @returns {void}
656
+ */
657
+ recordAsyncCrash(kind, reason) {
658
+ const error = reason instanceof Error ? reason : new Error(`${kind}: ${String(reason)}`)
659
+ const near = this._lastTestContext
660
+ const attribution = near ? `, near test: ${near.fullDescription} (${near.filePath}:${near.line})` : ""
661
+
662
+ this._failedTests = (this._failedTests || 0) + 1
663
+ this._failedTestDetails.push({
664
+ fullDescription: `<${kind} during test run${attribution}>`,
665
+ filePath: near ? near.filePath : "<test runner>",
666
+ line: near ? near.line : 0,
667
+ error,
668
+ consoleOutput: undefined
669
+ })
670
+
671
+ console.error(picocolors.red(`\n[test-runner] ${kind} during the test run — this would otherwise terminate the process silently and surface only as a crashed/retried shard with zero reported failures.${attribution}`))
672
+ console.error(error)
673
+ }
674
+
641
675
  async run() {
642
- await this.getConfiguration().ensureConnections({name: "Test runner suite"}, async () => {
643
- await this.runTests({
644
- afterEaches: [],
645
- beforeEaches: [],
646
- tests,
647
- descriptions: [],
648
- indentLevel: 0
676
+ /**
677
+ * Handles a process-level unhandled rejection during the run.
678
+ * @param {unknown} reason - Rejection reason.
679
+ * @returns {void}
680
+ */
681
+ const onUnhandledRejection = (reason) => {
682
+ // If a test attached its OWN unhandledRejection listener, it is
683
+ // intentionally observing/triggering the rejection (e.g. beacon
684
+ // error-reporting-spec.js) — Node dispatches to EVERY listener, so also
685
+ // failing the suite here would break those tests. Defer to the test's
686
+ // handler; only treat a rejection as a silent-death crash when ours is the
687
+ // sole listener (no persistent framework listener exists to mask this).
688
+ if (process.listenerCount("unhandledRejection") > 1) return
689
+
690
+ this.recordAsyncCrash("unhandledRejection", reason)
691
+ }
692
+
693
+ process.on("unhandledRejection", onUnhandledRejection)
694
+
695
+ try {
696
+ await this.getConfiguration().ensureConnections({name: "Test runner suite"}, async () => {
697
+ await this.runTests({
698
+ afterEaches: [],
699
+ beforeEaches: [],
700
+ tests,
701
+ descriptions: [],
702
+ indentLevel: 0
703
+ })
704
+
705
+ // A rejection scheduled by the final test (a detached rejected promise,
706
+ // or an afterCommit callback rejecting as the suite drains) is reported
707
+ // by Node on a LATER turn. Drain a few turns while the handler is still
708
+ // attached — and connections still open — so those late rejections are
709
+ // recorded instead of escaping to the default crash path after cleanup.
710
+ for (let drainTurn = 0; drainTurn < 3; drainTurn++) {
711
+ await new Promise((resolve) => setImmediate(resolve))
712
+ }
649
713
  })
650
- })
714
+ } finally {
715
+ process.off("unhandledRejection", onUnhandledRejection)
716
+ }
651
717
  }
652
718
 
653
719
  /**
@@ -798,6 +864,14 @@ export default class TestRunner {
798
864
  await beforeEachData.callback({configuration: this.getConfiguration(), testArgs, testData})
799
865
  }
800
866
 
867
+ // Record which test is running so an async crash (an unhandled
868
+ // rejection detached from any await) that fires during or shortly
869
+ // after this test can be attributed to it in run()'s handler.
870
+ this._lastTestContext = {
871
+ fullDescription: this.buildFullDescription(descriptions, testDescription),
872
+ filePath: testData.filePath ?? "<unknown>",
873
+ line: testData.line ?? 0
874
+ }
801
875
  await testData.function(testArgs)
802
876
  this._successfulTests++
803
877
  } finally {