dynalitics 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +261 -2
- package/dist/adapter-version.d.ts +3 -0
- package/dist/adapter-version.js +3 -0
- package/dist/adapter.d.ts +12 -0
- package/dist/adapter.js +7 -0
- package/dist/cli/io.d.ts +9 -0
- package/dist/cli/io.js +10 -0
- package/dist/cli/run.d.ts +4 -0
- package/dist/cli/run.js +39 -0
- package/dist/cli/sql.d.ts +5 -0
- package/dist/cli/sql.js +129 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +5 -0
- package/dist/config/schema.d.ts +64 -0
- package/dist/config/schema.js +248 -0
- package/dist/config/validate.d.ts +30 -0
- package/dist/config/validate.js +68 -0
- package/dist/create.d.ts +16 -0
- package/dist/create.js +308 -0
- package/dist/dialect/common.d.ts +54 -0
- package/dist/dialect/common.js +197 -0
- package/dist/dialect/index.d.ts +5 -0
- package/dist/dialect/index.js +8 -0
- package/dist/dialect/mssql.d.ts +3 -0
- package/dist/dialect/mssql.js +104 -0
- package/dist/dialect/mysql.d.ts +3 -0
- package/dist/dialect/mysql.js +140 -0
- package/dist/dialect/postgres.d.ts +5 -0
- package/dist/dialect/postgres.js +90 -0
- package/dist/dialect/types.d.ts +79 -0
- package/dist/dialect/types.js +2 -0
- package/dist/driver/types.d.ts +74 -0
- package/dist/driver/types.js +66 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.js +39 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/internals.d.ts +20 -0
- package/dist/internals.js +8 -0
- package/dist/logger.d.ts +10 -0
- package/dist/logger.js +24 -0
- package/dist/model/column-model.d.ts +7 -0
- package/dist/model/column-model.js +240 -0
- package/dist/model/identifiers.d.ts +19 -0
- package/dist/model/identifiers.js +62 -0
- package/dist/model/physical.d.ts +19 -0
- package/dist/model/physical.js +70 -0
- package/dist/model/types.d.ts +92 -0
- package/dist/model/types.js +9 -0
- package/dist/protocol.d.ts +222 -0
- package/dist/protocol.js +5 -0
- package/dist/queue/pipeline.d.ts +46 -0
- package/dist/queue/pipeline.js +282 -0
- package/dist/read/adapters.d.ts +7 -0
- package/dist/read/adapters.js +75 -0
- package/dist/read/buckets.d.ts +7 -0
- package/dist/read/buckets.js +39 -0
- package/dist/read/capabilities.d.ts +39 -0
- package/dist/read/capabilities.js +65 -0
- package/dist/read/handler.d.ts +48 -0
- package/dist/read/handler.js +63 -0
- package/dist/read/http.d.ts +18 -0
- package/dist/read/http.js +65 -0
- package/dist/read/index.d.ts +5 -0
- package/dist/read/index.js +3 -0
- package/dist/read/lock/authorize.d.ts +22 -0
- package/dist/read/lock/authorize.js +32 -0
- package/dist/read/lock/base-path.d.ts +11 -0
- package/dist/read/lock/base-path.js +12 -0
- package/dist/read/lock/gate.d.ts +30 -0
- package/dist/read/lock/gate.js +24 -0
- package/dist/read/lock/index.d.ts +28 -0
- package/dist/read/lock/index.js +46 -0
- package/dist/read/lock/options.d.ts +13 -0
- package/dist/read/lock/options.js +18 -0
- package/dist/read/lock/serve-html.d.ts +6 -0
- package/dist/read/lock/serve-html.js +38 -0
- package/dist/read/mask.d.ts +12 -0
- package/dist/read/mask.js +55 -0
- package/dist/read/params.d.ts +56 -0
- package/dist/read/params.js +150 -0
- package/dist/read/physical.d.ts +11 -0
- package/dist/read/physical.js +23 -0
- package/dist/read/queries.d.ts +75 -0
- package/dist/read/queries.js +316 -0
- package/dist/read/report.d.ts +21 -0
- package/dist/read/report.js +32 -0
- package/dist/read/routes.d.ts +38 -0
- package/dist/read/routes.js +261 -0
- package/dist/read/scope.d.ts +11 -0
- package/dist/read/scope.js +47 -0
- package/dist/read/search/fields.d.ts +24 -0
- package/dist/read/search/fields.js +67 -0
- package/dist/read/search/parse.d.ts +48 -0
- package/dist/read/search/parse.js +124 -0
- package/dist/read/semaphore.d.ts +6 -0
- package/dist/read/semaphore.js +34 -0
- package/dist/read/service.d.ts +42 -0
- package/dist/read/service.js +266 -0
- package/dist/read/types.d.ts +62 -0
- package/dist/read/types.js +2 -0
- package/dist/redact.d.ts +8 -0
- package/dist/redact.js +60 -0
- package/dist/report.d.ts +12 -0
- package/dist/report.js +39 -0
- package/dist/schema/generate.d.ts +46 -0
- package/dist/schema/generate.js +71 -0
- package/dist/schema/index.d.ts +5 -0
- package/dist/schema/index.js +3 -0
- package/dist/startup/compare.d.ts +22 -0
- package/dist/startup/compare.js +165 -0
- package/dist/startup/fix-sql.d.ts +15 -0
- package/dist/startup/fix-sql.js +94 -0
- package/dist/startup/privileges.d.ts +21 -0
- package/dist/startup/privileges.js +63 -0
- package/dist/startup/session-timezone.d.ts +10 -0
- package/dist/startup/session-timezone.js +19 -0
- package/dist/startup/type-families.d.ts +10 -0
- package/dist/startup/type-families.js +55 -0
- package/dist/startup/unmapped-notice.d.ts +5 -0
- package/dist/startup/unmapped-notice.js +28 -0
- package/dist/track/build-row.d.ts +50 -0
- package/dist/track/build-row.js +208 -0
- package/dist/track/custom.d.ts +31 -0
- package/dist/track/custom.js +83 -0
- package/dist/track/denied-view.d.ts +14 -0
- package/dist/track/denied-view.js +21 -0
- package/dist/track/first-visit.d.ts +19 -0
- package/dist/track/first-visit.js +70 -0
- package/dist/track/ip.d.ts +17 -0
- package/dist/track/ip.js +87 -0
- package/dist/track/limits.d.ts +7 -0
- package/dist/track/limits.js +26 -0
- package/dist/types.d.ts +136 -0
- package/dist/types.js +2 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +3 -0
- package/package.json +71 -4
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { ADAPTER_API_VERSION } from '../adapter-version.js';
|
|
2
|
+
import { DynaliticsConfigError } from '../errors.js';
|
|
3
|
+
import { assertTableForDialect, parseTableRef } from '../model/identifiers.js';
|
|
4
|
+
import { anyObject, bool, custom, fn, int, obj, oneOf, rec, req, runRule, str, } from './validate.js';
|
|
5
|
+
const ROLES = [
|
|
6
|
+
'timestamp',
|
|
7
|
+
'consent',
|
|
8
|
+
'pageVisited',
|
|
9
|
+
'ip',
|
|
10
|
+
'userId',
|
|
11
|
+
'username',
|
|
12
|
+
'email',
|
|
13
|
+
'custom',
|
|
14
|
+
'isFirstVisit',
|
|
15
|
+
'id',
|
|
16
|
+
];
|
|
17
|
+
const MIN_SALT = 16;
|
|
18
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
19
|
+
const credentialRule = (v, path, issues) => {
|
|
20
|
+
if (!isObj(v))
|
|
21
|
+
return void issues.push(`${path}: expected an object`);
|
|
22
|
+
const hasCs = Object.hasOwn(v, 'connectionString');
|
|
23
|
+
const hasPool = Object.hasOwn(v, 'pool');
|
|
24
|
+
if (hasCs && hasPool)
|
|
25
|
+
return void issues.push(`${path}: provide exactly one of connectionString or pool`);
|
|
26
|
+
if (!hasCs && !hasPool)
|
|
27
|
+
return void issues.push(`${path}: provide connectionString or pool`);
|
|
28
|
+
obj({ connectionString: str({ min: 1 }), pool: anyObject })(v, path, issues);
|
|
29
|
+
};
|
|
30
|
+
const connectionRule = (v, path, issues) => {
|
|
31
|
+
if (!isObj(v))
|
|
32
|
+
return void issues.push(`${path}: expected an object`);
|
|
33
|
+
const single = Object.hasOwn(v, 'connectionString') || Object.hasOwn(v, 'pool');
|
|
34
|
+
const split = Object.hasOwn(v, 'writer') || Object.hasOwn(v, 'reader');
|
|
35
|
+
if (single && split) {
|
|
36
|
+
return void issues.push(`${path}: use either a single credential (connectionString or pool) or { writer, reader }, not both`);
|
|
37
|
+
}
|
|
38
|
+
if (single)
|
|
39
|
+
return credentialRule(v, path, issues);
|
|
40
|
+
if (!split)
|
|
41
|
+
return void issues.push(`${path}: provide connectionString, pool, or { writer, reader }`);
|
|
42
|
+
obj({ writer: credentialRule, reader: credentialRule })(v, path, issues);
|
|
43
|
+
};
|
|
44
|
+
const DIALECTS = ['postgres', 'mysql', 'mssql'];
|
|
45
|
+
const ADAPTER_HINT = 'expected an adapter from dynalitics-postgres, dynalitics-mysql or dynalitics-mssql';
|
|
46
|
+
/** Spec 5.2 (v0.5): structural check, so an adapter built against another copy of dynalitics still passes. */
|
|
47
|
+
const adapterRule = (v, path, issues) => {
|
|
48
|
+
if (!isObj(v))
|
|
49
|
+
return void issues.push(`${path}: ${ADAPTER_HINT}`);
|
|
50
|
+
const { apiVersion, dialect, createDriver, packageName } = v;
|
|
51
|
+
if (typeof createDriver !== 'function' || !DIALECTS.includes(dialect))
|
|
52
|
+
return void issues.push(`${path}: ${ADAPTER_HINT}`);
|
|
53
|
+
if (apiVersion !== ADAPTER_API_VERSION) {
|
|
54
|
+
const name = typeof packageName === 'string' ? packageName : 'the adapter';
|
|
55
|
+
issues.push(`${path}: ${name} implements adapter API ${String(apiVersion)}, this dynalitics needs ${ADAPTER_API_VERSION}`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const dialectOf = (c) => {
|
|
59
|
+
const a = c['adapter'];
|
|
60
|
+
return isObj(a) && DIALECTS.includes(a['dialect'])
|
|
61
|
+
? a['dialect']
|
|
62
|
+
: undefined;
|
|
63
|
+
};
|
|
64
|
+
const tableRule = (v, path, issues) => {
|
|
65
|
+
if (typeof v === 'string')
|
|
66
|
+
return str({ min: 1 })(v, path, issues);
|
|
67
|
+
if (isObj(v))
|
|
68
|
+
return obj({ database: str(), schema: str(), name: req(str({ min: 1 })) })(v, path, issues);
|
|
69
|
+
issues.push(`${path}: expected a string or { schema?, name }`);
|
|
70
|
+
};
|
|
71
|
+
const columnsRule = obj(Object.fromEntries(ROLES.map((role) => [
|
|
72
|
+
role,
|
|
73
|
+
custom((v, path, issues) => {
|
|
74
|
+
if (v !== false && typeof v !== 'string')
|
|
75
|
+
issues.push(`${path}: expected a column name or false`);
|
|
76
|
+
}),
|
|
77
|
+
])));
|
|
78
|
+
const customFieldRule = obj({
|
|
79
|
+
type: req(oneOf(['string', 'integer', 'number', 'boolean'])),
|
|
80
|
+
maxLength: int({ min: 1, max: 1024 }),
|
|
81
|
+
personal: bool,
|
|
82
|
+
column: custom((v, path, issues) => {
|
|
83
|
+
if (v !== true && typeof v !== 'string')
|
|
84
|
+
issues.push(`${path}: expected true or a column name`);
|
|
85
|
+
}),
|
|
86
|
+
breakdown: bool,
|
|
87
|
+
keepWhenDenied: bool,
|
|
88
|
+
});
|
|
89
|
+
const notYet = (what) => (_v, path, issues) => void issues.push(`${path}: ${what} is not supported yet (planned for phase 2)`);
|
|
90
|
+
const configRule = obj({
|
|
91
|
+
adapter: req(adapterRule),
|
|
92
|
+
connection: req(connectionRule),
|
|
93
|
+
acknowledgeSingleCredential: bool,
|
|
94
|
+
poolSize: int({ min: 1, max: 20 }),
|
|
95
|
+
connectTimeoutMs: int({ min: 100, max: 120_000 }),
|
|
96
|
+
table: req(tableRule),
|
|
97
|
+
tableMode: oneOf(['recommended', 'existing']),
|
|
98
|
+
columns: columnsRule,
|
|
99
|
+
customFields: rec(customFieldRule),
|
|
100
|
+
extraColumns: notYet('extraColumns'),
|
|
101
|
+
page: obj({ keepQuery: bool, maxLength: int({ min: 1, max: 700 }), normalize: fn }),
|
|
102
|
+
ip: obj({
|
|
103
|
+
mode: oneOf(['raw', 'hash', 'truncate']),
|
|
104
|
+
salt: str(),
|
|
105
|
+
ipv4Prefix: int({ min: 8, max: 32 }),
|
|
106
|
+
ipv6Prefix: int({ min: 16, max: 128 }),
|
|
107
|
+
}),
|
|
108
|
+
consent: obj({ default: fn }),
|
|
109
|
+
firstVisit: obj({ enabled: bool, cacheSize: int({ min: 1, max: 5_000_000 }) }),
|
|
110
|
+
queue: obj({
|
|
111
|
+
maxSize: int({ min: 1, max: 1_000_000 }),
|
|
112
|
+
batchSize: int({ min: 1, max: 1000 }),
|
|
113
|
+
flushIntervalMs: int({ min: 10, max: 600_000 }),
|
|
114
|
+
backpressure: oneOf(['drop', 'block']),
|
|
115
|
+
maxBlockMs: int({ min: 1, max: 600_000 }),
|
|
116
|
+
retry: obj({
|
|
117
|
+
attempts: int({ min: 1, max: 10 }),
|
|
118
|
+
baseDelayMs: int({ min: 1, max: 60_000 }),
|
|
119
|
+
maxDelayMs: int({ min: 1, max: 600_000 }),
|
|
120
|
+
}),
|
|
121
|
+
}),
|
|
122
|
+
limits: obj({
|
|
123
|
+
customMaxKeys: int({ min: 1, max: 100 }),
|
|
124
|
+
customMaxBytes: int({ min: 16, max: 65_536 }),
|
|
125
|
+
oversize: oneOf(['truncate', 'reject']),
|
|
126
|
+
}),
|
|
127
|
+
validateOnStart: oneOf(['strict', 'skip']),
|
|
128
|
+
onError: fn,
|
|
129
|
+
logger: custom((v, path, issues) => {
|
|
130
|
+
if (!isObj(v))
|
|
131
|
+
return void issues.push(`${path}: expected an object with info, warn and error functions`);
|
|
132
|
+
obj({ info: req(fn), warn: req(fn), error: req(fn) })(v, path, issues);
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
/** Rule for the JSON file accepted by `dynalitics sql --config`: table-shape settings only, no secrets, no functions. */
|
|
136
|
+
export const schemaFileRule = obj({
|
|
137
|
+
tableMode: oneOf(['recommended', 'existing']),
|
|
138
|
+
columns: columnsRule,
|
|
139
|
+
customFields: rec(customFieldRule),
|
|
140
|
+
page: obj({ maxLength: int({ min: 1, max: 700 }) }),
|
|
141
|
+
firstVisit: obj({ enabled: bool }),
|
|
142
|
+
});
|
|
143
|
+
function crossChecks(c, issues) {
|
|
144
|
+
if (c['tableMode'] === 'existing' && c['validateOnStart'] === 'skip') {
|
|
145
|
+
issues.push('config.validateOnStart: "skip" is only allowed with tableMode "recommended"; an existing table is always checked at startup');
|
|
146
|
+
}
|
|
147
|
+
const ip = c['ip'];
|
|
148
|
+
if (isObj(ip) && ip['mode'] === 'hash') {
|
|
149
|
+
const salt = ip['salt'];
|
|
150
|
+
if (salt === undefined)
|
|
151
|
+
issues.push('config.ip.salt is required when ip.mode is "hash"');
|
|
152
|
+
else if (typeof salt === 'string' && salt.length < MIN_SALT) {
|
|
153
|
+
issues.push(`config.ip.salt must be at least ${MIN_SALT} characters`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const table = c['table'];
|
|
157
|
+
const dialect = dialectOf(c);
|
|
158
|
+
if ((typeof table === 'string' || isObj(table)) && dialect !== undefined) {
|
|
159
|
+
try {
|
|
160
|
+
const ref = parseTableRef(table);
|
|
161
|
+
assertTableForDialect(ref, dialect);
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
if (e instanceof DynaliticsConfigError)
|
|
165
|
+
issues.push(...e.issues.map((m) => `config.table: ${m}`));
|
|
166
|
+
else
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Shape and cross-field validation. Throws one DynaliticsConfigError listing every issue. */
|
|
172
|
+
export function validateConfig(input) {
|
|
173
|
+
if (!isObj(input))
|
|
174
|
+
throw new DynaliticsConfigError(['config: expected an object']);
|
|
175
|
+
const issues = runRule(configRule, input, 'config');
|
|
176
|
+
crossChecks(input, issues);
|
|
177
|
+
if (issues.length > 0)
|
|
178
|
+
throw new DynaliticsConfigError(issues);
|
|
179
|
+
return input;
|
|
180
|
+
}
|
|
181
|
+
function resolveCredentials(connection) {
|
|
182
|
+
if ('connectionString' in connection || 'pool' in connection) {
|
|
183
|
+
const cred = connection;
|
|
184
|
+
return { single: true, writer: cred, reader: cred };
|
|
185
|
+
}
|
|
186
|
+
const split = connection;
|
|
187
|
+
return {
|
|
188
|
+
single: false,
|
|
189
|
+
...(split.writer === undefined ? {} : { writer: split.writer }),
|
|
190
|
+
...(split.reader === undefined ? {} : { reader: split.reader }),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Applies defaults (spec 4.3). Pure: returns a new object, never mutates the config. */
|
|
194
|
+
export function resolveConfig(c) {
|
|
195
|
+
return {
|
|
196
|
+
adapter: c.adapter,
|
|
197
|
+
dialect: c.adapter.dialect,
|
|
198
|
+
credentials: resolveCredentials(c.connection),
|
|
199
|
+
acknowledgeSingleCredential: c.acknowledgeSingleCredential === true,
|
|
200
|
+
poolSize: { writer: c.poolSize ?? 4, reader: c.poolSize ?? 2 },
|
|
201
|
+
connectTimeoutMs: c.connectTimeoutMs ?? 10_000,
|
|
202
|
+
schema: {
|
|
203
|
+
table: c.table,
|
|
204
|
+
...(c.tableMode === undefined ? {} : { tableMode: c.tableMode }),
|
|
205
|
+
...(c.columns === undefined ? {} : { columns: c.columns }),
|
|
206
|
+
...(c.customFields === undefined ? {} : { customFields: c.customFields }),
|
|
207
|
+
page: { maxLength: c.page?.maxLength ?? 512 },
|
|
208
|
+
firstVisit: { enabled: c.firstVisit?.enabled === true },
|
|
209
|
+
},
|
|
210
|
+
page: {
|
|
211
|
+
keepQuery: c.page?.keepQuery === true,
|
|
212
|
+
maxLength: c.page?.maxLength ?? 512,
|
|
213
|
+
normalize: c.page?.normalize,
|
|
214
|
+
},
|
|
215
|
+
ip: {
|
|
216
|
+
mode: c.ip?.mode ?? 'truncate',
|
|
217
|
+
salt: c.ip?.salt,
|
|
218
|
+
ipv4Prefix: c.ip?.ipv4Prefix ?? 24,
|
|
219
|
+
ipv6Prefix: c.ip?.ipv6Prefix ?? 48,
|
|
220
|
+
},
|
|
221
|
+
consentDefault: c.consent?.default,
|
|
222
|
+
firstVisit: {
|
|
223
|
+
enabled: c.firstVisit?.enabled === true,
|
|
224
|
+
cacheSize: c.firstVisit?.cacheSize ?? 50_000,
|
|
225
|
+
},
|
|
226
|
+
queue: {
|
|
227
|
+
maxSize: c.queue?.maxSize ?? 10_000,
|
|
228
|
+
batchSize: c.queue?.batchSize ?? 200,
|
|
229
|
+
flushIntervalMs: c.queue?.flushIntervalMs ?? 1000,
|
|
230
|
+
backpressure: c.queue?.backpressure ?? 'drop',
|
|
231
|
+
maxBlockMs: c.queue?.maxBlockMs ?? 2000,
|
|
232
|
+
retry: {
|
|
233
|
+
attempts: c.queue?.retry?.attempts ?? 3,
|
|
234
|
+
baseDelayMs: c.queue?.retry?.baseDelayMs ?? 200,
|
|
235
|
+
maxDelayMs: c.queue?.retry?.maxDelayMs ?? 5000,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
limits: {
|
|
239
|
+
customMaxKeys: c.limits?.customMaxKeys ?? 20,
|
|
240
|
+
customMaxBytes: c.limits?.customMaxBytes ?? 4096,
|
|
241
|
+
oversize: c.limits?.oversize ?? 'truncate',
|
|
242
|
+
},
|
|
243
|
+
validateOnStart: c.validateOnStart ?? 'strict',
|
|
244
|
+
onError: c.onError,
|
|
245
|
+
logger: c.logger,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny declarative validator (no dependencies). Each rule appends human-readable issues with a dotted path;
|
|
3
|
+
* nothing throws, so the caller can report every problem at once. Values are never echoed back, only their kind,
|
|
4
|
+
* so secrets cannot leak through validation messages.
|
|
5
|
+
*/
|
|
6
|
+
export type Issues = string[];
|
|
7
|
+
export type Rule = (value: unknown, path: string, issues: Issues) => void;
|
|
8
|
+
export declare const str: (o?: {
|
|
9
|
+
min?: number;
|
|
10
|
+
max?: number;
|
|
11
|
+
pattern?: RegExp;
|
|
12
|
+
hint?: string;
|
|
13
|
+
}) => Rule;
|
|
14
|
+
export declare const int: (o: {
|
|
15
|
+
min: number;
|
|
16
|
+
max: number;
|
|
17
|
+
}) => Rule;
|
|
18
|
+
export declare const bool: Rule;
|
|
19
|
+
export declare const oneOf: (allowed: readonly string[]) => Rule;
|
|
20
|
+
export declare const fn: Rule;
|
|
21
|
+
export declare const anyObject: Rule;
|
|
22
|
+
/** Marks a key as required inside obj(). */
|
|
23
|
+
export declare const req: (rule: Rule) => Rule & {
|
|
24
|
+
required: true;
|
|
25
|
+
};
|
|
26
|
+
export declare const obj: (shape: Readonly<Record<string, Rule>>) => Rule;
|
|
27
|
+
export declare const rec: (rule: Rule) => Rule;
|
|
28
|
+
export declare const custom: (check: (value: unknown, path: string, issues: Issues) => void) => Rule;
|
|
29
|
+
export declare function runRule(rule: Rule, value: unknown, path: string): Issues;
|
|
30
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const kindOf = (v) => v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v;
|
|
2
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
3
|
+
export const str = (o = {}) => (v, path, issues) => {
|
|
4
|
+
if (typeof v !== 'string')
|
|
5
|
+
return void issues.push(`${path}: expected a string, got ${kindOf(v)}`);
|
|
6
|
+
if (o.min !== undefined && v.length < o.min)
|
|
7
|
+
issues.push(`${path}: expected at least ${o.min} characters`);
|
|
8
|
+
if (o.max !== undefined && v.length > o.max)
|
|
9
|
+
issues.push(`${path}: expected at most ${o.max} characters`);
|
|
10
|
+
if (o.pattern !== undefined && !o.pattern.test(v))
|
|
11
|
+
issues.push(`${path}: ${o.hint ?? 'has an invalid format'}`);
|
|
12
|
+
};
|
|
13
|
+
export const int = (o) => (v, path, issues) => {
|
|
14
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < o.min || v > o.max) {
|
|
15
|
+
issues.push(`${path}: expected integer ${o.min}..${o.max}, got ${typeof v === 'number' ? String(v) : kindOf(v)}`);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
export const bool = (v, path, issues) => {
|
|
19
|
+
if (typeof v !== 'boolean')
|
|
20
|
+
issues.push(`${path}: expected a boolean, got ${kindOf(v)}`);
|
|
21
|
+
};
|
|
22
|
+
export const oneOf = (allowed) => (v, path, issues) => {
|
|
23
|
+
if (typeof v !== 'string' || !allowed.includes(v)) {
|
|
24
|
+
issues.push(`${path}: expected one of ${allowed.map((a) => JSON.stringify(a)).join(', ')}`);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
export const fn = (v, path, issues) => {
|
|
28
|
+
if (typeof v !== 'function')
|
|
29
|
+
issues.push(`${path}: expected a function, got ${kindOf(v)}`);
|
|
30
|
+
};
|
|
31
|
+
export const anyObject = (v, path, issues) => {
|
|
32
|
+
if (typeof v !== 'object' || v === null)
|
|
33
|
+
issues.push(`${path}: expected an object, got ${kindOf(v)}`);
|
|
34
|
+
};
|
|
35
|
+
/** Marks a key as required inside obj(). */
|
|
36
|
+
export const req = (rule) => Object.assign((v, path, issues) => rule(v, path, issues), {
|
|
37
|
+
required: true,
|
|
38
|
+
});
|
|
39
|
+
export const obj = (shape) => (v, path, issues) => {
|
|
40
|
+
if (!isPlainObject(v))
|
|
41
|
+
return void issues.push(`${path}: expected an object, got ${kindOf(v)}`);
|
|
42
|
+
for (const key of Object.keys(v)) {
|
|
43
|
+
if (!Object.hasOwn(shape, key))
|
|
44
|
+
issues.push(`${path}.${key}: unknown key`);
|
|
45
|
+
}
|
|
46
|
+
for (const [key, rule] of Object.entries(shape)) {
|
|
47
|
+
const present = Object.hasOwn(v, key) && v[key] !== undefined;
|
|
48
|
+
if (!present) {
|
|
49
|
+
if (rule.required === true)
|
|
50
|
+
issues.push(`${path}.${key}: required`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
rule(v[key], `${path}.${key}`, issues);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
export const rec = (rule) => (v, path, issues) => {
|
|
57
|
+
if (!isPlainObject(v))
|
|
58
|
+
return void issues.push(`${path}: expected an object, got ${kindOf(v)}`);
|
|
59
|
+
for (const [key, value] of Object.entries(v))
|
|
60
|
+
rule(value, `${path}.${key}`, issues);
|
|
61
|
+
};
|
|
62
|
+
export const custom = (check) => (v, path, issues) => check(v, path, issues);
|
|
63
|
+
export function runRule(rule, value, path) {
|
|
64
|
+
const issues = [];
|
|
65
|
+
rule(value, path, issues);
|
|
66
|
+
return issues;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=validate.js.map
|
package/dist/create.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Driver, type DriverAdapter, type DriverOptions } from './driver/types.js';
|
|
2
|
+
import type { Dynalitics, DynaliticsConfig } from './types.js';
|
|
3
|
+
export interface CreateDeps {
|
|
4
|
+
readonly createDriver: (adapter: DriverAdapter, options: DriverOptions) => Promise<Driver>;
|
|
5
|
+
readonly now: () => Date;
|
|
6
|
+
readonly sleep: (ms: number) => Promise<void>;
|
|
7
|
+
readonly random: () => number;
|
|
8
|
+
}
|
|
9
|
+
/** Same as createDynalitics but with injectable I/O, for tests. */
|
|
10
|
+
export declare function createDynaliticsWith(config: DynaliticsConfig, deps: CreateDeps): Promise<Dynalitics>;
|
|
11
|
+
/**
|
|
12
|
+
* Connects, validates the table (never changes it) and returns a tracker.
|
|
13
|
+
* Throws DynaliticsConfigError, DynaliticsConnectionError or DynaliticsSchemaError (never with connection details).
|
|
14
|
+
*/
|
|
15
|
+
export declare function createDynalitics(config: DynaliticsConfig): Promise<Dynalitics>;
|
|
16
|
+
//# sourceMappingURL=create.d.ts.map
|
package/dist/create.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { resolveConfig, validateConfig } from './config/schema.js';
|
|
3
|
+
import { getDialect } from './dialect/index.js';
|
|
4
|
+
import { isDriverError, } from './driver/types.js';
|
|
5
|
+
import { DynaliticsConnectionError, DynaliticsSchemaError } from './errors.js';
|
|
6
|
+
import { registerInternals } from './internals.js';
|
|
7
|
+
import { consoleLogger, safeLogger } from './logger.js';
|
|
8
|
+
import { buildColumnModel } from './model/column-model.js';
|
|
9
|
+
import { adoptPhysical } from './model/physical.js';
|
|
10
|
+
import { createPipeline } from './queue/pipeline.js';
|
|
11
|
+
import { createRedactor, sanitizeError, secretsFromConnectionString, } from './redact.js';
|
|
12
|
+
import { createReporter } from './report.js';
|
|
13
|
+
import { compareTable } from './startup/compare.js';
|
|
14
|
+
import { buildFixSql, buildSchemaReport } from './startup/fix-sql.js';
|
|
15
|
+
import { inspectPrivileges } from './startup/privileges.js';
|
|
16
|
+
import { probeSessionTimeDiff } from './startup/session-timezone.js';
|
|
17
|
+
import { unmappedNotice } from './startup/unmapped-notice.js';
|
|
18
|
+
import { buildRow } from './track/build-row.js';
|
|
19
|
+
import { createFirstVisit } from './track/first-visit.js';
|
|
20
|
+
const realDeps = {
|
|
21
|
+
createDriver: (adapter, options) => adapter.createDriver(options),
|
|
22
|
+
now: () => new Date(),
|
|
23
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
24
|
+
random: Math.random,
|
|
25
|
+
};
|
|
26
|
+
const STARTUP_QUERY_TIMEOUT_MS = 10_000;
|
|
27
|
+
const SINGLE_CREDENTIAL_WARNING = "dynalitics is using ONE database credential for both writing and reading. Anyone who can use the web process's credential can therefore read the personal data (ip, email, username, userId) it writes. Use separate writer and reader credentials: `npx dynalitics sql --grants-only --dialect <dialect> --table <table>` prints the GRANT statements.";
|
|
28
|
+
const secretsOf = (c) => c !== undefined && 'connectionString' in c ? secretsFromConnectionString(c.connectionString) : [];
|
|
29
|
+
function asConnectionError(err, what, redact) {
|
|
30
|
+
if (err instanceof DynaliticsConnectionError)
|
|
31
|
+
return err;
|
|
32
|
+
const cause = isDriverError(err)
|
|
33
|
+
? (err.safeCause ?? sanitizeError(err, redact))
|
|
34
|
+
: sanitizeError(err, redact);
|
|
35
|
+
return new DynaliticsConnectionError(`${what}: ${cause.message}`, cause);
|
|
36
|
+
}
|
|
37
|
+
/** MySQL: a TIMESTAMP role column makes the session time zone matter (spec 3.9). Nothing is asked otherwise. */
|
|
38
|
+
async function sessionTimeDiffFor(model, columns, dialect, drivers, redact) {
|
|
39
|
+
const ts = model.columns.find((c) => c.role === 'timestamp');
|
|
40
|
+
const real = columns.find((c) => c.name.toLowerCase() === ts?.name.toLowerCase());
|
|
41
|
+
if (dialect.name !== 'mysql' || real?.dataType !== 'timestamp')
|
|
42
|
+
return undefined;
|
|
43
|
+
try {
|
|
44
|
+
return await probeSessionTimeDiff(drivers, STARTUP_QUERY_TIMEOUT_MS);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
throw asConnectionError(err, 'Could not check the session time zone', redact);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function checkStructure(inspector, used, hasReader, model, dialect, logger, redact) {
|
|
51
|
+
let rows;
|
|
52
|
+
try {
|
|
53
|
+
rows = await inspector.query(dialect.introspectSql(model.table), {
|
|
54
|
+
timeoutMs: STARTUP_QUERY_TIMEOUT_MS,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
throw asConnectionError(err, 'Could not read the table structure', redact);
|
|
59
|
+
}
|
|
60
|
+
const columns = rows.map((r) => dialect.mapColumnRow(r));
|
|
61
|
+
const sessionTimeDiff = await sessionTimeDiffFor(model, columns, dialect, used, redact);
|
|
62
|
+
const result = compareTable({
|
|
63
|
+
model,
|
|
64
|
+
dialect,
|
|
65
|
+
columns,
|
|
66
|
+
...(sessionTimeDiff === undefined ? {} : { sessionTimeDiff }),
|
|
67
|
+
});
|
|
68
|
+
let problems = result.problems;
|
|
69
|
+
if (!hasReader && columns.length === 0) {
|
|
70
|
+
const table = dialect.qualifiedTable(model.table);
|
|
71
|
+
problems = [
|
|
72
|
+
{
|
|
73
|
+
code: 'identity_not_discoverable',
|
|
74
|
+
message: model.mode === 'existing'
|
|
75
|
+
? `The writer credential cannot see the columns of ${table} (or the table does not exist), so the structure cannot be checked. Add a reader credential that can see the table, and check the table name in the config.`
|
|
76
|
+
: `The writer credential cannot see the columns of ${table} (or the table does not exist), so the structure cannot be checked. Add a reader credential, or set validateOnStart: 'skip'. To get the recommended table, run: npx dynalitics sql --dialect ${dialect.name} --table ${model.table.name}`,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
if (problems.length > 0) {
|
|
81
|
+
const input = { model, dialect, problems, warnings: result.warnings };
|
|
82
|
+
throw new DynaliticsSchemaError(buildSchemaReport(input), problems, result.warnings, buildFixSql(input));
|
|
83
|
+
}
|
|
84
|
+
for (const w of result.warnings)
|
|
85
|
+
logger.warn(w.message);
|
|
86
|
+
return { schema: columns[0]?.schema, columns };
|
|
87
|
+
}
|
|
88
|
+
/** Same as createDynalitics but with injectable I/O, for tests. */
|
|
89
|
+
export async function createDynaliticsWith(config, deps) {
|
|
90
|
+
const cfg = resolveConfig(validateConfig(config));
|
|
91
|
+
let model = buildColumnModel(cfg.schema);
|
|
92
|
+
const dialect = getDialect(cfg.dialect);
|
|
93
|
+
const { writer: writerCred, reader: readerCred, single } = cfg.credentials;
|
|
94
|
+
const redact = createRedactor([
|
|
95
|
+
...secretsOf(writerCred),
|
|
96
|
+
...secretsOf(readerCred),
|
|
97
|
+
...(cfg.ip.salt === undefined ? [] : [cfg.ip.salt]),
|
|
98
|
+
]);
|
|
99
|
+
const logger = safeLogger(cfg.logger ?? consoleLogger, redact);
|
|
100
|
+
const reporter = createReporter(cfg.onError, redact, deps.now);
|
|
101
|
+
if (single && !cfg.acknowledgeSingleCredential)
|
|
102
|
+
logger.warn(SINGLE_CREDENTIAL_WARNING);
|
|
103
|
+
const opened = [];
|
|
104
|
+
const closeAll = async () => {
|
|
105
|
+
await Promise.allSettled([...new Set(opened)].map((d) => d.close()));
|
|
106
|
+
};
|
|
107
|
+
const open = async (cred, max) => {
|
|
108
|
+
try {
|
|
109
|
+
const d = await deps.createDriver(cfg.adapter, {
|
|
110
|
+
credential: cred,
|
|
111
|
+
max,
|
|
112
|
+
connectTimeoutMs: cfg.connectTimeoutMs,
|
|
113
|
+
redact,
|
|
114
|
+
});
|
|
115
|
+
opened.push(d);
|
|
116
|
+
return d;
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
throw asConnectionError(err, 'Could not set up the database driver', redact);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
let writerDriver;
|
|
123
|
+
let readerDriver;
|
|
124
|
+
let adopted = [];
|
|
125
|
+
try {
|
|
126
|
+
if (writerCred !== undefined)
|
|
127
|
+
writerDriver = await open(writerCred, cfg.poolSize.writer);
|
|
128
|
+
if (single)
|
|
129
|
+
readerDriver = writerDriver;
|
|
130
|
+
else if (readerCred !== undefined)
|
|
131
|
+
readerDriver = await open(readerCred, cfg.poolSize.reader);
|
|
132
|
+
if (cfg.validateOnStart === 'skip') {
|
|
133
|
+
logger.warn('validateOnStart is "skip": the table structure and privileges were NOT checked.');
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
for (const d of new Set([writerDriver, readerDriver])) {
|
|
137
|
+
if (d === undefined)
|
|
138
|
+
continue;
|
|
139
|
+
try {
|
|
140
|
+
await d.query({ sql: 'SELECT 1', params: [] }, { timeoutMs: cfg.connectTimeoutMs });
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
throw asConnectionError(err, 'Could not connect to the database', redact);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const inspector = readerDriver ?? writerDriver;
|
|
147
|
+
if (inspector !== undefined) {
|
|
148
|
+
const used = [...new Set([writerDriver, readerDriver])].filter((d) => d !== undefined);
|
|
149
|
+
const found = await checkStructure(inspector, used, readerDriver !== undefined, model, dialect, logger, redact);
|
|
150
|
+
const real = adoptPhysical(model, found.columns, dialect.name);
|
|
151
|
+
model = real.model;
|
|
152
|
+
adopted = real.adopted;
|
|
153
|
+
if (found.schema !== undefined && model.table.schema === undefined) {
|
|
154
|
+
// Introspection and INSERT/SELECT must look at the same table, not at whatever search_path resolves later.
|
|
155
|
+
model = { ...model, table: { ...model.table, schema: found.schema } };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const checks = single
|
|
159
|
+
? [[writerDriver, 'single']]
|
|
160
|
+
: [
|
|
161
|
+
[writerDriver, 'writer'],
|
|
162
|
+
[readerDriver, 'reader'],
|
|
163
|
+
];
|
|
164
|
+
for (const [driver, kind] of checks) {
|
|
165
|
+
if (driver === undefined)
|
|
166
|
+
continue;
|
|
167
|
+
const r = await inspectPrivileges({
|
|
168
|
+
driver,
|
|
169
|
+
dialect,
|
|
170
|
+
table: model.table,
|
|
171
|
+
kind,
|
|
172
|
+
firstVisitEnabled: model.firstVisitEnabled,
|
|
173
|
+
});
|
|
174
|
+
if (r.warning !== undefined)
|
|
175
|
+
logger.warn(r.warning);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
await closeAll();
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
logger.info(`using table ${dialect.qualifiedTable(model.table)} (tableMode "${model.mode}")`);
|
|
184
|
+
for (const a of adopted) {
|
|
185
|
+
logger.info(`column "${a.column}" holds at most ${a.real} characters, less than the configured ${a.configured}; longer values are ${cfg.limits.oversize === 'reject' ? 'rejected' : 'cut to fit'}.`);
|
|
186
|
+
}
|
|
187
|
+
const notice = unmappedNotice(model);
|
|
188
|
+
if (notice !== undefined)
|
|
189
|
+
logger.info(notice);
|
|
190
|
+
const firstVisit = model.firstVisitEnabled && writerDriver !== undefined
|
|
191
|
+
? createFirstVisit({
|
|
192
|
+
cacheSize: cfg.firstVisit.cacheSize,
|
|
193
|
+
processKey: randomBytes(32),
|
|
194
|
+
maxParams: dialect.limits.maxParams,
|
|
195
|
+
lookup: async (kind, values) => {
|
|
196
|
+
const rows = await writerDriver.query(dialect.firstVisitLookup(model, kind, values), {
|
|
197
|
+
timeoutMs: STARTUP_QUERY_TIMEOUT_MS,
|
|
198
|
+
});
|
|
199
|
+
return new Set(rows.map((r) => String(r['v'])));
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
: undefined;
|
|
203
|
+
const pipeline = writerDriver === undefined
|
|
204
|
+
? undefined
|
|
205
|
+
: createPipeline({
|
|
206
|
+
driver: writerDriver,
|
|
207
|
+
dialect,
|
|
208
|
+
model,
|
|
209
|
+
queue: cfg.queue,
|
|
210
|
+
firstVisit,
|
|
211
|
+
report: (info) => reporter.report(info),
|
|
212
|
+
sleep: (ms) => deps.sleep(ms),
|
|
213
|
+
random: () => deps.random(),
|
|
214
|
+
now: () => deps.now().getTime(),
|
|
215
|
+
requestTimeoutMs: STARTUP_QUERY_TIMEOUT_MS,
|
|
216
|
+
});
|
|
217
|
+
let rejected = 0;
|
|
218
|
+
const pageColumn = model.columns.find((c) => c.role === 'pageVisited')?.type;
|
|
219
|
+
const rowConfig = {
|
|
220
|
+
// The real column may be shorter than page.maxLength (existing tables): never build a value that cannot be stored.
|
|
221
|
+
page: {
|
|
222
|
+
...cfg.page,
|
|
223
|
+
maxLength: pageColumn?.kind === 'string' ? pageColumn.maxLength : cfg.page.maxLength,
|
|
224
|
+
},
|
|
225
|
+
ip: cfg.ip,
|
|
226
|
+
limits: cfg.limits,
|
|
227
|
+
consentDefault: cfg.consentDefault,
|
|
228
|
+
};
|
|
229
|
+
let closing;
|
|
230
|
+
const dyn = {
|
|
231
|
+
model,
|
|
232
|
+
table: model.table,
|
|
233
|
+
capabilities: { canWrite: pipeline !== undefined, canRead: readerDriver !== undefined },
|
|
234
|
+
async track(visit) {
|
|
235
|
+
try {
|
|
236
|
+
if (pipeline === undefined)
|
|
237
|
+
return { status: 'rejected', issues: ['no writer credential configured'] };
|
|
238
|
+
const built = buildRow({ model, dialect, cfg: rowConfig, visit, now: deps.now() });
|
|
239
|
+
built.notes.forEach((n) => reporter.note(n));
|
|
240
|
+
if (!built.ok) {
|
|
241
|
+
rejected++;
|
|
242
|
+
reporter.note({
|
|
243
|
+
code: 'invalid_visit',
|
|
244
|
+
message: `visit rejected: ${built.issues.join('; ')}`,
|
|
245
|
+
});
|
|
246
|
+
return { status: 'rejected', issues: built.issues };
|
|
247
|
+
}
|
|
248
|
+
return await pipeline.enqueue({ values: built.values, identity: built.identity });
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
rejected++;
|
|
252
|
+
reporter.report({
|
|
253
|
+
code: 'internal',
|
|
254
|
+
message: 'unexpected error while tracking a visit',
|
|
255
|
+
cause: sanitizeError(err, redact),
|
|
256
|
+
});
|
|
257
|
+
return { status: 'rejected', issues: ['internal error'] };
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
async flush(opts) {
|
|
261
|
+
return pipeline === undefined
|
|
262
|
+
? { written: 0, failed: 0, timedOut: false }
|
|
263
|
+
: pipeline.flush(opts);
|
|
264
|
+
},
|
|
265
|
+
close(opts) {
|
|
266
|
+
closing ??= (async () => {
|
|
267
|
+
const res = pipeline === undefined
|
|
268
|
+
? { written: 0, failed: 0, timedOut: false }
|
|
269
|
+
: await pipeline.close(opts);
|
|
270
|
+
await closeAll();
|
|
271
|
+
return res;
|
|
272
|
+
})();
|
|
273
|
+
return closing;
|
|
274
|
+
},
|
|
275
|
+
stats() {
|
|
276
|
+
const s = pipeline?.stats();
|
|
277
|
+
return {
|
|
278
|
+
queued: s?.queued ?? 0,
|
|
279
|
+
written: s?.written ?? 0,
|
|
280
|
+
droppedFull: s?.droppedFull ?? 0,
|
|
281
|
+
rejected,
|
|
282
|
+
failed: s?.failed ?? 0,
|
|
283
|
+
queueLength: s?.queueLength ?? 0,
|
|
284
|
+
lruSize: firstVisit?.size() ?? 0,
|
|
285
|
+
lastErrorAt: reporter.lastErrorAt(),
|
|
286
|
+
};
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
registerInternals(dyn, {
|
|
290
|
+
reader: readerDriver,
|
|
291
|
+
dialect,
|
|
292
|
+
model,
|
|
293
|
+
ipMode: cfg.ip.mode,
|
|
294
|
+
redact,
|
|
295
|
+
report: (info) => reporter.report(info),
|
|
296
|
+
logger,
|
|
297
|
+
now: deps.now,
|
|
298
|
+
});
|
|
299
|
+
return dyn;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Connects, validates the table (never changes it) and returns a tracker.
|
|
303
|
+
* Throws DynaliticsConfigError, DynaliticsConnectionError or DynaliticsSchemaError (never with connection details).
|
|
304
|
+
*/
|
|
305
|
+
export function createDynalitics(config) {
|
|
306
|
+
return createDynaliticsWith(config, realDeps);
|
|
307
|
+
}
|
|
308
|
+
//# sourceMappingURL=create.js.map
|