circuitjson-toolkit 1.0.16 → 1.1.0
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/AGENTS.md +15 -0
- package/NOTICE.md +31 -0
- package/README.md +267 -107
- package/docs/api.md +501 -96
- package/docs/capabilities.md +70 -0
- package/docs/migration/behaviors.md +45 -0
- package/docs/migration/parser.md +60 -0
- package/docs/migration/renderers.md +515 -0
- package/docs/migration/root.md +740 -0
- package/docs/migration.md +120 -0
- package/docs/model-format.md +175 -57
- package/docs/provenance.md +206 -0
- package/docs/release-notes-v1.1.0.md +154 -0
- package/docs/testing.md +117 -7
- package/package.json +31 -5
- package/spec/api-baseline-v1.0.17.json +1 -0
- package/spec/baseline-provenance-v1.0.17.json +7 -0
- package/spec/circuitjson-schema-snapshot.json +321 -0
- package/spec/circuitjson-schema-source.json +28 -0
- package/spec/feature-preservation.json +1 -0
- package/spec/library-scope.md +27 -20
- package/src/capabilities.mjs +1 -0
- package/src/core/ArchiveEntryPath.mjs +93 -0
- package/src/core/ArchiveLimits.mjs +31 -0
- package/src/core/ArchiveLimitsValidator.mjs +107 -0
- package/src/core/AsyncInputOwnership.mjs +56 -0
- package/src/core/AttachedValueLimits.mjs +67 -0
- package/src/core/CircuitJsonDiagnosticIndexer.mjs +184 -0
- package/src/core/CircuitJsonDocument.mjs +19 -61
- package/src/core/CircuitJsonElementTypes.mjs +10 -0
- package/src/core/CircuitJsonElementValidator.mjs +98 -847
- package/src/core/CircuitJsonIndexer.mjs +274 -194
- package/src/core/CircuitJsonManufacturingBuilder.mjs +167 -164
- package/src/core/CircuitJsonParser.mjs +75 -13
- package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +12 -6
- package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +108 -10
- package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +1 -1
- package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +44 -38
- package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +146 -28
- package/src/core/CircuitJsonPcbPrimitiveFields.mjs +70 -4
- package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +18 -2
- package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +26 -9
- package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +7 -6
- package/src/core/CircuitJsonSerializedInputAudit.mjs +87 -0
- package/src/core/CircuitJsonSourceMetadata.mjs +5 -1
- package/src/core/CircuitJsonSupportMatrixBuilder.mjs +3 -1
- package/src/core/CircuitJsonToolkitElementSchema.mjs +218 -0
- package/src/core/CircuitJsonUnitParsers.mjs +101 -0
- package/src/core/CircuitJsonUnits.mjs +13 -87
- package/src/core/CircuitJsonUpstreamSchema.mjs +9 -0
- package/src/core/CircuitJsonUpstreamValidator.mjs +418 -0
- package/src/core/CircuitJsonValidationUnits.mjs +6 -0
- package/src/core/ManufacturingService.mjs +323 -0
- package/src/core/Parser.mjs +343 -0
- package/src/core/ParserOptions.mjs +333 -0
- package/src/core/PcbBoundsSelectionModel.mjs +55 -19
- package/src/core/PcbDiagnosticFocusModel.mjs +42 -11
- package/src/core/PcbInteractionIndex.mjs +368 -0
- package/src/core/PcbInteractionPrimitiveModel.mjs +393 -62
- package/src/core/ProjectAsyncInputOwner.mjs +70 -0
- package/src/core/ProjectLoader.mjs +975 -0
- package/src/core/SimulationService.mjs +790 -0
- package/src/core/ToolkitCapabilities.mjs +130 -0
- package/src/core/ZipArchiveInspector.mjs +649 -0
- package/src/core/context/BinaryDataSnapshot.mjs +217 -0
- package/src/core/context/CircuitJsonContextIndexes.mjs +96 -0
- package/src/core/context/CircuitJsonDerivedCache.mjs +114 -0
- package/src/core/context/CircuitJsonDocumentContext.mjs +353 -0
- package/src/core/context/CircuitJsonLegacyModel.mjs +147 -0
- package/src/core/context/CircuitJsonLegacyNormalizer.mjs +847 -0
- package/src/core/context/CircuitJsonMetadataBoundary.mjs +76 -0
- package/src/core/context/CircuitJsonModelFreezeTraversal.mjs +179 -0
- package/src/core/context/CircuitJsonReadOnlyDocument.mjs +920 -0
- package/src/core/context/CircuitJsonSchematicTableNormalizer.mjs +314 -0
- package/src/core/context/CircuitJsonValidationAuthority.mjs +39 -0
- package/src/core/context/CircuitJsonValidationProof.mjs +217 -0
- package/src/core/context/PcbPrimitivePreparation.mjs +198 -0
- package/src/core/context/PcbSpatialIndex.mjs +701 -0
- package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +128 -0
- package/src/core/context/StructuredDataSnapshot.mjs +683 -0
- package/src/core/contracts/DocumentResult.mjs +198 -0
- package/src/core/contracts/ProjectResult.mjs +96 -0
- package/src/core/contracts/RuntimeProxyBoundary.mjs +48 -0
- package/src/core/contracts/ToolkitAsset.mjs +493 -0
- package/src/core/contracts/ToolkitDiagnostic.mjs +38 -0
- package/src/core/contracts/ToolkitError.mjs +176 -0
- package/src/core/contracts/ToolkitProgress.mjs +89 -0
- package/src/core/interaction/CanonicalInteractionOptions.mjs +246 -0
- package/src/core/interaction/PcbInteractionBounds.mjs +167 -0
- package/src/core/query/CircuitTraversal.mjs +343 -0
- package/src/core/query/ComponentGrouping.mjs +275 -0
- package/src/core/query/QueryNetlistBuilder.mjs +306 -0
- package/src/core/query/QueryService.mjs +435 -0
- package/src/core/query/RegexPattern.mjs +75 -0
- package/src/core/rendering/CanonicalBomOrder.mjs +81 -0
- package/src/core/rendering/CanonicalBomRows.mjs +92 -0
- package/src/core/rendering/CanonicalRenderOptions.mjs +498 -0
- package/src/core/rendering/CanonicalSvgDocument.mjs +102 -0
- package/src/core/rendering/PcbRenderPlan.mjs +429 -0
- package/src/core/rendering/SchematicSheetSelector.mjs +335 -0
- package/src/core/scene3d/PcbScene3dBuilder.mjs +906 -0
- package/src/core/scene3d/PcbScene3dPreparator.mjs +47 -0
- package/src/core/scene3d/Scene3dAssetIndex.mjs +284 -0
- package/src/core/scene3d/Scene3dBoardModel.mjs +596 -0
- package/src/core/scene3d/Scene3dDocumentMetadata.mjs +167 -0
- package/src/core/scene3d/Scene3dFreeze.mjs +37 -0
- package/src/core/scene3d/Scene3dIdRegistry.mjs +34 -0
- package/src/core/scene3d/Scene3dInputPreflight.mjs +193 -0
- package/src/core/scene3d/Scene3dMaterials.mjs +58 -0
- package/src/core/scene3d/Scene3dModelReference.mjs +134 -0
- package/src/core/scene3d/Scene3dOptions.mjs +363 -0
- package/src/core/scene3d/SceneAssetResolver.mjs +441 -0
- package/src/core/simulation/SimulationParameterCloner.mjs +543 -0
- package/src/core/worker/ParserWorkerClient.mjs +997 -0
- package/src/core/worker/ToolkitWorkerProtocol.mjs +412 -0
- package/src/core/worker/WorkerRequestData.mjs +623 -0
- package/src/core/worker/WorkerResponseData.mjs +905 -0
- package/src/extensions.mjs +37 -0
- package/src/index.mjs +14 -9
- package/src/interaction.mjs +2 -0
- package/src/manufacturing.mjs +1 -0
- package/src/parser.mjs +12 -2
- package/src/project.mjs +5 -0
- package/src/query.mjs +1 -0
- package/src/renderers.mjs +3 -29
- package/src/scene3d.mjs +3 -0
- package/src/simulation.mjs +1 -0
- package/src/styles/renderers.css +24 -0
- package/src/testing/ToolkitContractFixtures.mjs +124 -0
- package/src/testing/ToolkitLoopbackWorker.mjs +174 -0
- package/src/testing/runToolkitContract.mjs +705 -0
- package/src/testing.mjs +3 -0
- package/src/ui/BomTableRenderer.mjs +304 -0
- package/src/ui/CircuitJsonPcbBoardSvgRenderer.mjs +80 -0
- package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +3 -5
- package/src/ui/CircuitJsonPcbSvgRenderer.mjs +63 -43
- package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +3 -5
- package/src/ui/CircuitJsonSchematicDebugRenderer.mjs +164 -0
- package/src/ui/CircuitJsonSchematicImageSvgRenderer.mjs +210 -0
- package/src/ui/CircuitJsonSchematicLineRenderer.mjs +86 -0
- package/src/ui/CircuitJsonSchematicSheetSymbolSvgRenderer.mjs +98 -0
- package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +117 -17
- package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +67 -20
- package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +45 -9
- package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +151 -148
- package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +4 -292
- package/src/ui/PcbSvgRenderer.mjs +41 -0
- package/src/ui/SafeSvgPaint.mjs +26 -0
- package/src/ui/SafeXmlText.mjs +60 -0
- package/src/ui/SchematicGeometryBounds.mjs +540 -0
- package/src/ui/SchematicSvgRenderer.mjs +110 -0
- package/src/ui/SchematicTableGeometry.mjs +319 -0
- package/src/ui/SchematicTextAnchor.mjs +55 -0
- package/src/ui/SchematicTextBounds.mjs +98 -0
- package/src/workers/parser.worker.mjs +59 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { CircuitJsonDocumentContext } from '../context/CircuitJsonDocumentContext.mjs'
|
|
2
|
+
import { ToolkitError } from '../contracts/ToolkitError.mjs'
|
|
3
|
+
import { CircuitTraversal } from './CircuitTraversal.mjs'
|
|
4
|
+
import { QueryNetlistBuilder } from './QueryNetlistBuilder.mjs'
|
|
5
|
+
import { RegexPattern } from './RegexPattern.mjs'
|
|
6
|
+
|
|
7
|
+
const COMPONENT_FIELDS = new Set([
|
|
8
|
+
'id',
|
|
9
|
+
'name',
|
|
10
|
+
'designator',
|
|
11
|
+
'type',
|
|
12
|
+
'value',
|
|
13
|
+
'footprint',
|
|
14
|
+
'mpn',
|
|
15
|
+
'description'
|
|
16
|
+
])
|
|
17
|
+
const NET_FIELDS = new Set(['id', 'name'])
|
|
18
|
+
const CRITERIA_KEYS = new Set([
|
|
19
|
+
'field',
|
|
20
|
+
'pattern',
|
|
21
|
+
'match',
|
|
22
|
+
'flags',
|
|
23
|
+
'caseSensitive'
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Provides repeated clone-safe queries over one prepared CircuitJSON context.
|
|
28
|
+
*/
|
|
29
|
+
export class QueryService {
|
|
30
|
+
#context
|
|
31
|
+
#netlist
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates one service around an already prepared context.
|
|
35
|
+
* @param {CircuitJsonDocumentContext} context Prepared document context.
|
|
36
|
+
*/
|
|
37
|
+
constructor(context) {
|
|
38
|
+
this.#context = context
|
|
39
|
+
this.#netlist = context.getOrCreateDerived('query', 'netlist-v1', () =>
|
|
40
|
+
QueryNetlistBuilder.build(context)
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates a bound query service and prepares its shared indexes once.
|
|
46
|
+
* @param {unknown} document Document result, CircuitJSON model, or context.
|
|
47
|
+
* @param {Record<string, any>} [options] Context options.
|
|
48
|
+
* @returns {QueryService} Bound service.
|
|
49
|
+
*/
|
|
50
|
+
static create(document, options = {}) {
|
|
51
|
+
try {
|
|
52
|
+
const normalizedOptions = QueryService.#record(
|
|
53
|
+
options,
|
|
54
|
+
new Set(['indexes'])
|
|
55
|
+
)
|
|
56
|
+
const context = CircuitJsonDocumentContext.prepare(document, {
|
|
57
|
+
...normalizedOptions,
|
|
58
|
+
indexes: ['elements', 'relations', 'connectivity']
|
|
59
|
+
})
|
|
60
|
+
return new QueryService(context)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw QueryService.#from(error)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns shared context work counters in stable query terminology.
|
|
68
|
+
* @returns {Record<string, number>} Query statistics.
|
|
69
|
+
*/
|
|
70
|
+
get statistics() {
|
|
71
|
+
const statistics = this.#context.statistics
|
|
72
|
+
return {
|
|
73
|
+
validationPasses: statistics.validationPasses,
|
|
74
|
+
elementIndexBuilds: statistics.indexBuilds.elements || 0,
|
|
75
|
+
relationIndexBuilds: statistics.indexBuilds.relations || 0,
|
|
76
|
+
connectivityIndexBuilds: statistics.indexBuilds.connectivity || 0,
|
|
77
|
+
netlistBuilds: statistics.derivedBuilds['query:netlist-v1'] || 0
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Executes one canonical component or net query.
|
|
83
|
+
* @param {Record<string, any>} request Query request.
|
|
84
|
+
* @param {Record<string, any>} [options] Paging options.
|
|
85
|
+
* @returns {{ schema: string, items: object[], diagnostics: object[], statistics: object }} Query result.
|
|
86
|
+
*/
|
|
87
|
+
query(request, options = {}) {
|
|
88
|
+
const normalized = QueryService.#record(
|
|
89
|
+
request,
|
|
90
|
+
new Set(['select', 'where'])
|
|
91
|
+
)
|
|
92
|
+
const select = normalized.select
|
|
93
|
+
if (select !== 'components' && select !== 'nets') {
|
|
94
|
+
throw QueryService.#error(
|
|
95
|
+
'Query select must be components or nets.'
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
const items = this.#find(
|
|
99
|
+
select,
|
|
100
|
+
normalized.where === undefined ? {} : normalized.where,
|
|
101
|
+
options
|
|
102
|
+
)
|
|
103
|
+
return {
|
|
104
|
+
schema: 'ecad-toolkit.query.v1',
|
|
105
|
+
items,
|
|
106
|
+
diagnostics: structuredClone(this.#netlist.diagnostics),
|
|
107
|
+
statistics: this.statistics
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Finds components using exact, contains, or regex matching.
|
|
113
|
+
* @param {Record<string, any>} [criteria] Component criteria.
|
|
114
|
+
* @param {Record<string, any>} [options] Paging options.
|
|
115
|
+
* @returns {object[]} Matching component records.
|
|
116
|
+
*/
|
|
117
|
+
findComponents(criteria = {}, options = {}) {
|
|
118
|
+
return this.#find('components', criteria, options)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Finds nets using exact, contains, or regex matching.
|
|
123
|
+
* @param {Record<string, any>} [criteria] Net criteria.
|
|
124
|
+
* @param {Record<string, any>} [options] Paging options.
|
|
125
|
+
* @returns {object[]} Matching net records.
|
|
126
|
+
*/
|
|
127
|
+
findNets(criteria = {}, options = {}) {
|
|
128
|
+
return this.#find('nets', criteria, options)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Traverses stable source connectivity from canonical source ids.
|
|
133
|
+
* @param {Record<string, any>} request Starting source ids.
|
|
134
|
+
* @param {Record<string, any>} [options] Traversal bounds.
|
|
135
|
+
* @returns {object[]} Ordered connectivity records.
|
|
136
|
+
*/
|
|
137
|
+
traceConnectivity(request, options = {}) {
|
|
138
|
+
const start = QueryService.#startRequest(request)
|
|
139
|
+
const bounds = QueryService.#traversalOptions(options)
|
|
140
|
+
const graph = this.#context.getOrCreateDerived(
|
|
141
|
+
'query',
|
|
142
|
+
'traversal-v1',
|
|
143
|
+
() => CircuitTraversal.prepare(this.#netlist)
|
|
144
|
+
)
|
|
145
|
+
return CircuitTraversal.trace(graph, start, bounds)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Returns a detached canonical query netlist.
|
|
150
|
+
* @param {Record<string, any>} [options] Reserved build options.
|
|
151
|
+
* @returns {Record<string, any>} Clone-safe netlist.
|
|
152
|
+
*/
|
|
153
|
+
buildNetlist(options = {}) {
|
|
154
|
+
QueryService.#record(options, new Set())
|
|
155
|
+
return structuredClone(this.#netlist)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Filters one stable record family.
|
|
160
|
+
* @param {'components' | 'nets'} select Record family.
|
|
161
|
+
* @param {Record<string, any>} criteria Match criteria.
|
|
162
|
+
* @param {Record<string, any>} options Paging options.
|
|
163
|
+
* @returns {object[]} Detached matching rows.
|
|
164
|
+
*/
|
|
165
|
+
#find(select, criteria, options) {
|
|
166
|
+
const fields = select === 'components' ? COMPONENT_FIELDS : NET_FIELDS
|
|
167
|
+
const matcher = QueryService.#matcher(criteria, fields)
|
|
168
|
+
const paging = QueryService.#paging(options)
|
|
169
|
+
const source =
|
|
170
|
+
select === 'components'
|
|
171
|
+
? this.#netlist.components
|
|
172
|
+
: this.#netlist.nets
|
|
173
|
+
return structuredClone(
|
|
174
|
+
source
|
|
175
|
+
.filter((row) => QueryService.#matches(row, matcher, fields))
|
|
176
|
+
.slice(paging.offset, paging.offset + paging.limit)
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Normalizes safe matching criteria.
|
|
182
|
+
* @param {unknown} criteria Criteria candidate.
|
|
183
|
+
* @param {Set<string>} fields Allowed row fields.
|
|
184
|
+
* @returns {Record<string, any>} Matcher state.
|
|
185
|
+
*/
|
|
186
|
+
static #matcher(criteria, fields) {
|
|
187
|
+
const normalized = QueryService.#record(criteria, CRITERIA_KEYS)
|
|
188
|
+
const pattern =
|
|
189
|
+
normalized.pattern === undefined ? '' : normalized.pattern
|
|
190
|
+
if (typeof pattern !== 'string' || pattern.length > 4096) {
|
|
191
|
+
throw QueryService.#error('Query pattern must be a bounded string.')
|
|
192
|
+
}
|
|
193
|
+
const match =
|
|
194
|
+
normalized.match === undefined ? 'contains' : normalized.match
|
|
195
|
+
if (!['exact', 'contains', 'regex'].includes(match)) {
|
|
196
|
+
throw QueryService.#error(
|
|
197
|
+
'Query match must be exact, contains, or regex.'
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
const field = normalized.field
|
|
201
|
+
if (
|
|
202
|
+
field !== undefined &&
|
|
203
|
+
(typeof field !== 'string' || !fields.has(field))
|
|
204
|
+
) {
|
|
205
|
+
throw QueryService.#error(
|
|
206
|
+
'Query field is unavailable for this selection.'
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
normalized.caseSensitive !== undefined &&
|
|
211
|
+
typeof normalized.caseSensitive !== 'boolean'
|
|
212
|
+
) {
|
|
213
|
+
throw QueryService.#error('Query caseSensitive must be boolean.')
|
|
214
|
+
}
|
|
215
|
+
const flags = normalized.flags === undefined ? '' : normalized.flags
|
|
216
|
+
if (match !== 'regex' && typeof flags !== 'string') {
|
|
217
|
+
throw QueryService.#error('Query regex flags must be a string.')
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
pattern,
|
|
221
|
+
match,
|
|
222
|
+
field,
|
|
223
|
+
caseSensitive: normalized.caseSensitive === true,
|
|
224
|
+
regex:
|
|
225
|
+
match === 'regex'
|
|
226
|
+
? RegexPattern.compile(pattern, flags, {
|
|
227
|
+
caseSensitive: normalized.caseSensitive === true
|
|
228
|
+
})
|
|
229
|
+
: null
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Tests one row against normalized criteria.
|
|
235
|
+
* @param {Record<string, any>} row Query row.
|
|
236
|
+
* @param {Record<string, any>} matcher Matcher state.
|
|
237
|
+
* @param {Set<string>} fields Searchable fields.
|
|
238
|
+
* @returns {boolean} Whether the row matches.
|
|
239
|
+
*/
|
|
240
|
+
static #matches(row, matcher, fields) {
|
|
241
|
+
if (!matcher.pattern) return true
|
|
242
|
+
const values = matcher.field
|
|
243
|
+
? [row[matcher.field]]
|
|
244
|
+
: [...fields].map((field) => row[field])
|
|
245
|
+
return values
|
|
246
|
+
.filter(
|
|
247
|
+
(value) =>
|
|
248
|
+
typeof value === 'string' || typeof value === 'number'
|
|
249
|
+
)
|
|
250
|
+
.some((value) => {
|
|
251
|
+
if (matcher.regex)
|
|
252
|
+
return RegexPattern.test(matcher.regex, value)
|
|
253
|
+
const candidate = String(value)
|
|
254
|
+
const pattern = matcher.pattern
|
|
255
|
+
const normalizedCandidate = matcher.caseSensitive
|
|
256
|
+
? candidate
|
|
257
|
+
: candidate.toLocaleLowerCase('en-US')
|
|
258
|
+
const normalizedPattern = matcher.caseSensitive
|
|
259
|
+
? pattern
|
|
260
|
+
: pattern.toLocaleLowerCase('en-US')
|
|
261
|
+
return matcher.match === 'exact'
|
|
262
|
+
? normalizedCandidate === normalizedPattern
|
|
263
|
+
: normalizedCandidate.includes(normalizedPattern)
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Normalizes bounded result paging.
|
|
269
|
+
* @param {unknown} options Paging options.
|
|
270
|
+
* @returns {{ limit: number, offset: number }} Paging bounds.
|
|
271
|
+
*/
|
|
272
|
+
static #paging(options) {
|
|
273
|
+
const normalized = QueryService.#record(
|
|
274
|
+
options,
|
|
275
|
+
new Set(['limit', 'offset'])
|
|
276
|
+
)
|
|
277
|
+
const limit = normalized.limit === undefined ? 10000 : normalized.limit
|
|
278
|
+
const offset = normalized.offset === undefined ? 0 : normalized.offset
|
|
279
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
|
|
280
|
+
throw QueryService.#error(
|
|
281
|
+
'Query limit must be an integer from 1 through 10000.'
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
285
|
+
throw QueryService.#error(
|
|
286
|
+
'Query offset must be a non-negative safe integer.'
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
return { limit, offset }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Normalizes one connectivity start request.
|
|
294
|
+
* @param {unknown} request Request candidate.
|
|
295
|
+
* @returns {Record<string, string>} Starting ids.
|
|
296
|
+
*/
|
|
297
|
+
static #startRequest(request) {
|
|
298
|
+
const keys = new Set([
|
|
299
|
+
'sourceTraceId',
|
|
300
|
+
'sourceComponentId',
|
|
301
|
+
'sourcePortId',
|
|
302
|
+
'sourceNetId'
|
|
303
|
+
])
|
|
304
|
+
const normalized = QueryService.#record(request, keys)
|
|
305
|
+
const result = {}
|
|
306
|
+
for (const key of keys) {
|
|
307
|
+
if (normalized[key] === undefined) continue
|
|
308
|
+
if (
|
|
309
|
+
typeof normalized[key] !== 'string' ||
|
|
310
|
+
!normalized[key].trim()
|
|
311
|
+
) {
|
|
312
|
+
throw QueryService.#error(
|
|
313
|
+
'Connectivity source ids must be non-empty strings.'
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
result[key] = normalized[key].trim()
|
|
317
|
+
}
|
|
318
|
+
if (!Object.keys(result).length) {
|
|
319
|
+
throw QueryService.#error(
|
|
320
|
+
'Connectivity traversal requires a canonical source id.'
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
return result
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Normalizes traversal work limits.
|
|
328
|
+
* @param {unknown} options Traversal options.
|
|
329
|
+
* @returns {{ maxDepth: number, maxResults: number }} Work bounds.
|
|
330
|
+
*/
|
|
331
|
+
static #traversalOptions(options) {
|
|
332
|
+
const normalized = QueryService.#record(
|
|
333
|
+
options,
|
|
334
|
+
new Set(['maxDepth', 'maxResults'])
|
|
335
|
+
)
|
|
336
|
+
const maxDepth =
|
|
337
|
+
normalized.maxDepth === undefined ? 64 : normalized.maxDepth
|
|
338
|
+
const maxResults =
|
|
339
|
+
normalized.maxResults === undefined ? 10000 : normalized.maxResults
|
|
340
|
+
if (
|
|
341
|
+
!Number.isSafeInteger(maxDepth) ||
|
|
342
|
+
maxDepth < 0 ||
|
|
343
|
+
maxDepth > 1024
|
|
344
|
+
) {
|
|
345
|
+
throw QueryService.#error(
|
|
346
|
+
'Connectivity maxDepth must be an integer from 0 through 1024.'
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
if (
|
|
350
|
+
!Number.isSafeInteger(maxResults) ||
|
|
351
|
+
maxResults < 1 ||
|
|
352
|
+
maxResults > 10000
|
|
353
|
+
) {
|
|
354
|
+
throw QueryService.#error(
|
|
355
|
+
'Connectivity maxResults must be an integer from 1 through 10000.'
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
return { maxDepth, maxResults }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Reads a plain record through own data properties only.
|
|
363
|
+
* @param {unknown} value Record candidate.
|
|
364
|
+
* @param {Set<string>} allowedKeys Allowed own keys.
|
|
365
|
+
* @returns {Record<string, any>} Safe shallow record.
|
|
366
|
+
*/
|
|
367
|
+
static #record(value, allowedKeys) {
|
|
368
|
+
try {
|
|
369
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
370
|
+
throw QueryService.#error(
|
|
371
|
+
'Query arguments must be plain objects.'
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
const prototype = Object.getPrototypeOf(value)
|
|
375
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
376
|
+
throw QueryService.#error(
|
|
377
|
+
'Query arguments must be plain objects.'
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
const descriptors = Object.getOwnPropertyDescriptors(value)
|
|
381
|
+
const result = {}
|
|
382
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
383
|
+
const descriptor = descriptors[key]
|
|
384
|
+
if (
|
|
385
|
+
typeof key !== 'string' ||
|
|
386
|
+
!allowedKeys.has(key) ||
|
|
387
|
+
descriptor.get ||
|
|
388
|
+
descriptor.set ||
|
|
389
|
+
descriptor.enumerable !== true
|
|
390
|
+
) {
|
|
391
|
+
throw QueryService.#error(
|
|
392
|
+
'Query arguments contain an unsupported field.'
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
result[key] = descriptor.value
|
|
396
|
+
}
|
|
397
|
+
return result
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error instanceof ToolkitError) throw error
|
|
400
|
+
throw QueryService.#error(
|
|
401
|
+
'Query arguments could not be inspected safely.',
|
|
402
|
+
error
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Normalizes construction failures into the public query boundary.
|
|
409
|
+
* @param {unknown} error Failure candidate.
|
|
410
|
+
* @returns {ToolkitError} Typed query error.
|
|
411
|
+
*/
|
|
412
|
+
static #from(error) {
|
|
413
|
+
return error instanceof ToolkitError
|
|
414
|
+
? error
|
|
415
|
+
: QueryService.#error(
|
|
416
|
+
error?.message || 'Query service preparation failed.',
|
|
417
|
+
error
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Creates one stable query request error.
|
|
423
|
+
* @param {string} message Failure message.
|
|
424
|
+
* @param {unknown} [cause] Native cause.
|
|
425
|
+
* @returns {ToolkitError} Typed query error.
|
|
426
|
+
*/
|
|
427
|
+
static #error(message, cause) {
|
|
428
|
+
return new ToolkitError(message, {
|
|
429
|
+
code: 'ERR_QUERY_REQUEST',
|
|
430
|
+
category: 'validation',
|
|
431
|
+
format: 'circuitjson',
|
|
432
|
+
cause
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ToolkitError } from '../contracts/ToolkitError.mjs'
|
|
2
|
+
|
|
3
|
+
const ALLOWED_FLAGS = new Set(['i', 'm', 's', 'u'])
|
|
4
|
+
const MAX_PATTERN_LENGTH = 4096
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compiles bounded regular-expression data without evaluating caller code.
|
|
8
|
+
*/
|
|
9
|
+
export class RegexPattern {
|
|
10
|
+
/**
|
|
11
|
+
* Compiles one validated pattern and deterministic flag set.
|
|
12
|
+
* @param {unknown} pattern Pattern source.
|
|
13
|
+
* @param {unknown} [flags] JavaScript regular-expression flags.
|
|
14
|
+
* @param {{ caseSensitive?: boolean }} [options] Matching options.
|
|
15
|
+
* @returns {RegExp} Compiled regular expression.
|
|
16
|
+
*/
|
|
17
|
+
static compile(pattern, flags = '', options = {}) {
|
|
18
|
+
if (
|
|
19
|
+
typeof pattern !== 'string' ||
|
|
20
|
+
pattern.length > MAX_PATTERN_LENGTH
|
|
21
|
+
) {
|
|
22
|
+
throw RegexPattern.#error(
|
|
23
|
+
'Query regex patterns must be bounded strings.'
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
if (typeof flags !== 'string') {
|
|
27
|
+
throw RegexPattern.#error('Query regex flags must be a string.')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const normalizedFlags = [...flags]
|
|
31
|
+
if (
|
|
32
|
+
new Set(normalizedFlags).size !== normalizedFlags.length ||
|
|
33
|
+
normalizedFlags.some((flag) => !ALLOWED_FLAGS.has(flag))
|
|
34
|
+
) {
|
|
35
|
+
throw RegexPattern.#error(
|
|
36
|
+
'Query regex flags must be unique and limited to i, m, s, and u.'
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
if (options.caseSensitive !== true && !normalizedFlags.includes('i')) {
|
|
40
|
+
normalizedFlags.push('i')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
return new RegExp(pattern, normalizedFlags.join(''))
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw RegexPattern.#error('Query regex pattern is invalid.', error)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Tests one value while resetting state defensively.
|
|
52
|
+
* @param {RegExp} regex Compiled expression.
|
|
53
|
+
* @param {unknown} value Candidate value.
|
|
54
|
+
* @returns {boolean} Whether the value matches.
|
|
55
|
+
*/
|
|
56
|
+
static test(regex, value) {
|
|
57
|
+
regex.lastIndex = 0
|
|
58
|
+
return regex.test(String(value ?? ''))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Creates a stable query-pattern validation error.
|
|
63
|
+
* @param {string} message Failure message.
|
|
64
|
+
* @param {unknown} [cause] Native compilation failure.
|
|
65
|
+
* @returns {ToolkitError} Typed query error.
|
|
66
|
+
*/
|
|
67
|
+
static #error(message, cause) {
|
|
68
|
+
return new ToolkitError(message, {
|
|
69
|
+
code: 'ERR_QUERY_PATTERN',
|
|
70
|
+
category: 'validation',
|
|
71
|
+
format: 'circuitjson',
|
|
72
|
+
cause
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provides one total deterministic ordering for canonical BOM presentation.
|
|
3
|
+
*/
|
|
4
|
+
export class CanonicalBomOrder {
|
|
5
|
+
/**
|
|
6
|
+
* Compares natural designator strings with a raw code-point tie break.
|
|
7
|
+
* @param {unknown} left Left designator.
|
|
8
|
+
* @param {unknown} right Right designator.
|
|
9
|
+
* @returns {number} Ordering value.
|
|
10
|
+
*/
|
|
11
|
+
static compareDesignators(left, right) {
|
|
12
|
+
const leftText = String(left)
|
|
13
|
+
const rightText = String(right)
|
|
14
|
+
const natural = leftText.localeCompare(rightText, 'en-US', {
|
|
15
|
+
numeric: true,
|
|
16
|
+
sensitivity: 'base'
|
|
17
|
+
})
|
|
18
|
+
return natural || CanonicalBomOrder.#compareText(leftText, rightText)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compares every rendered row field after its complete designator list.
|
|
23
|
+
* @param {object} left Left BOM row.
|
|
24
|
+
* @param {object} right Right BOM row.
|
|
25
|
+
* @returns {number} Total rendered-row order.
|
|
26
|
+
*/
|
|
27
|
+
static compareRows(left, right) {
|
|
28
|
+
const leftDesignators = left.designators || []
|
|
29
|
+
const rightDesignators = right.designators || []
|
|
30
|
+
const common = Math.min(leftDesignators.length, rightDesignators.length)
|
|
31
|
+
for (let index = 0; index < common; index += 1) {
|
|
32
|
+
const order = CanonicalBomOrder.compareDesignators(
|
|
33
|
+
leftDesignators[index],
|
|
34
|
+
rightDesignators[index]
|
|
35
|
+
)
|
|
36
|
+
if (order) return order
|
|
37
|
+
}
|
|
38
|
+
if (leftDesignators.length !== rightDesignators.length) {
|
|
39
|
+
return leftDesignators.length - rightDesignators.length
|
|
40
|
+
}
|
|
41
|
+
for (const field of ['value', 'pattern', 'source', 'quantity']) {
|
|
42
|
+
const order = CanonicalBomOrder.#compareScalar(
|
|
43
|
+
left[field],
|
|
44
|
+
right[field]
|
|
45
|
+
)
|
|
46
|
+
if (order) return order
|
|
47
|
+
}
|
|
48
|
+
return 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compares primitive rendered scalar values by type and value.
|
|
53
|
+
* @param {unknown} left Left scalar.
|
|
54
|
+
* @param {unknown} right Right scalar.
|
|
55
|
+
* @returns {number} Ordering value.
|
|
56
|
+
*/
|
|
57
|
+
static #compareScalar(left, right) {
|
|
58
|
+
if (left === right) return 0
|
|
59
|
+
const leftType = typeof left
|
|
60
|
+
const rightType = typeof right
|
|
61
|
+
if (leftType !== rightType) {
|
|
62
|
+
return CanonicalBomOrder.#compareText(leftType, rightType)
|
|
63
|
+
}
|
|
64
|
+
if (leftType === 'number') return left - right
|
|
65
|
+
if (leftType === 'boolean') return left ? 1 : -1
|
|
66
|
+
return CanonicalBomOrder.#compareText(
|
|
67
|
+
String(left ?? ''),
|
|
68
|
+
String(right ?? '')
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compares strings by code point.
|
|
74
|
+
* @param {string} left Left text.
|
|
75
|
+
* @param {string} right Right text.
|
|
76
|
+
* @returns {number} Ordering value.
|
|
77
|
+
*/
|
|
78
|
+
static #compareText(left, right) {
|
|
79
|
+
return left < right ? -1 : left > right ? 1 : 0
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { CircuitJsonBomBuilder } from '../CircuitJsonBomBuilder.mjs'
|
|
2
|
+
import { CanonicalBomOrder } from './CanonicalBomOrder.mjs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalizes deterministic canonical BOM presentation without changing legacy grouping.
|
|
6
|
+
*/
|
|
7
|
+
export class CanonicalBomRows {
|
|
8
|
+
/**
|
|
9
|
+
* Builds stable rows while keeping canonical electrical values as group identity.
|
|
10
|
+
* @param {object[]} model CircuitJSON model.
|
|
11
|
+
* @returns {object[]} Canonical BOM rows.
|
|
12
|
+
*/
|
|
13
|
+
static build(model) {
|
|
14
|
+
const labelsByDesignator = new Map()
|
|
15
|
+
for (const element of model || []) {
|
|
16
|
+
if (element?.type !== 'source_component') continue
|
|
17
|
+
const designator = CanonicalBomRows.#designator(element)
|
|
18
|
+
if (!designator) continue
|
|
19
|
+
const label = CanonicalBomRows.#displayValue(element)
|
|
20
|
+
if (!label) continue
|
|
21
|
+
const state = labelsByDesignator.get(designator)
|
|
22
|
+
if (!state) {
|
|
23
|
+
labelsByDesignator.set(designator, {
|
|
24
|
+
label,
|
|
25
|
+
ambiguous: false
|
|
26
|
+
})
|
|
27
|
+
} else if (state.label !== label) {
|
|
28
|
+
state.ambiguous = true
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return CircuitJsonBomBuilder.build(model)
|
|
32
|
+
.map((row) => {
|
|
33
|
+
const designators = [...row.designators].sort(
|
|
34
|
+
CanonicalBomOrder.compareDesignators
|
|
35
|
+
)
|
|
36
|
+
let displayLabel = ''
|
|
37
|
+
let ambiguous = false
|
|
38
|
+
for (const designator of designators) {
|
|
39
|
+
const state = labelsByDesignator.get(designator)
|
|
40
|
+
if (!state) continue
|
|
41
|
+
if (
|
|
42
|
+
state.ambiguous ||
|
|
43
|
+
(displayLabel && displayLabel !== state.label)
|
|
44
|
+
) {
|
|
45
|
+
ambiguous = true
|
|
46
|
+
break
|
|
47
|
+
}
|
|
48
|
+
displayLabel = state.label
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
...row,
|
|
52
|
+
designators,
|
|
53
|
+
value:
|
|
54
|
+
displayLabel && !ambiguous
|
|
55
|
+
? displayLabel
|
|
56
|
+
: String(row.value)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
.sort(CanonicalBomOrder.compareRows)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolves the designator used by the legacy BOM grouping contract.
|
|
64
|
+
* @param {object} component Source component.
|
|
65
|
+
* @returns {string} Designator.
|
|
66
|
+
*/
|
|
67
|
+
static #designator(component) {
|
|
68
|
+
return String(
|
|
69
|
+
component.name ||
|
|
70
|
+
component.reference ||
|
|
71
|
+
component.designator ||
|
|
72
|
+
component.source_component_id ||
|
|
73
|
+
''
|
|
74
|
+
).trim()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolves standard display-only electrical fields.
|
|
79
|
+
* @param {object} component Source component.
|
|
80
|
+
* @returns {string} Display label.
|
|
81
|
+
*/
|
|
82
|
+
static #displayValue(component) {
|
|
83
|
+
return String(
|
|
84
|
+
component.display_value ??
|
|
85
|
+
component.display_resistance ??
|
|
86
|
+
component.display_capacitance ??
|
|
87
|
+
component.display_inductance ??
|
|
88
|
+
component.display_max_resistance ??
|
|
89
|
+
''
|
|
90
|
+
).trim()
|
|
91
|
+
}
|
|
92
|
+
}
|