@sqlane/core 0.0.1 → 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.
@@ -0,0 +1,163 @@
1
+ import { parseDuration } from './duration.js';
2
+ import { ConfigError, InvalidOptionsError, MigrationLockTimeoutError } from './errors.js';
3
+ import { mysqlMigrations, mysqlMigrationsTable } from './schema/mysql.js';
4
+ import { postgresMigrations, postgresMigrationsTable } from './schema/postgres.js';
5
+ import { checkTablePrefix } from './validate.js';
6
+
7
+ /** @typedef {import('./types.js').Executor} Executor */
8
+
9
+ /**
10
+ * The schema of sqlane as versioned SQL, for the app's own migration tool.
11
+ * @param {object} options
12
+ * @param {'mysql' | 'postgres'} options.dialect
13
+ * @param {string} [options.tablePrefix] Default: `'sqlane_'`.
14
+ * @returns {import('./types.js').Migration[]}
15
+ */
16
+ export function getMigrations({ dialect, tablePrefix = 'sqlane_' }) {
17
+ const scope = { where: 'getMigrations', ErrorClass: ConfigError };
18
+
19
+ checkTablePrefix(tablePrefix, scope);
20
+
21
+ if (dialect === 'mysql') {
22
+ return mysqlMigrations(tablePrefix);
23
+ }
24
+
25
+ if (dialect === 'postgres') {
26
+ return postgresMigrations(tablePrefix);
27
+ }
28
+
29
+ throw new ConfigError(`getMigrations: "dialect" must be 'mysql' or 'postgres'`);
30
+ }
31
+
32
+ /**
33
+ * Runs the statements one after the other: DDL in order.
34
+ * @param {Executor} executor
35
+ * @param {{ sql: string, params: unknown[] }[]} steps
36
+ * @returns {Promise<void>}
37
+ */
38
+ async function runInOrder(executor, steps) {
39
+ for (const { sql, params } of steps) {
40
+ // oxlint-disable-next-line no-await-in-loop -- each statement needs the previous one
41
+ await executor.query(sql, params);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * The statements of the missing migrations, each followed by its record.
47
+ * @param {import('./types.js').Migration[]} missing
48
+ * @param {string} recordSql
49
+ * @returns {{ sql: string, params: unknown[] }[]}
50
+ */
51
+ function toSteps(missing, recordSql) {
52
+ return missing.flatMap(({ id, statements }) => [
53
+ ...statements.map(sql => ({ sql, params: [] })),
54
+ { sql: recordSql, params: [id] },
55
+ ]);
56
+ }
57
+
58
+ /**
59
+ * @param {Executor} executor
60
+ * @param {string} table
61
+ * @returns {Promise<Set<string>>}
62
+ */
63
+ async function readApplied(executor, table) {
64
+ const rows = await executor.query(`SELECT id FROM ${table}`, []);
65
+
66
+ return new Set(rows.map(row => String(row.id)));
67
+ }
68
+
69
+ /**
70
+ * @param {Executor} executor
71
+ * @param {string} p
72
+ * @param {number} lockTimeoutMs
73
+ * @returns {Promise<string[]>}
74
+ */
75
+ async function migrateMysql(executor, p, lockTimeoutMs) {
76
+ const lockName = `${p}migrations`;
77
+ // GET_LOCK waits whole seconds; -1 is forever, so at least 1
78
+ const [{ locked }] = await executor.query('SELECT GET_LOCK(?, ?) AS locked', [
79
+ lockName,
80
+ Math.max(1, Math.ceil(lockTimeoutMs / 1000)),
81
+ ]);
82
+
83
+ if (Number(locked) !== 1) {
84
+ throw new MigrationLockTimeoutError(`migrate: the lock "${lockName}" was not free in ${lockTimeoutMs} ms`);
85
+ }
86
+
87
+ try {
88
+ await executor.query(mysqlMigrationsTable(p), []);
89
+
90
+ const applied = await readApplied(executor, `${p}migrations`);
91
+ const missing = mysqlMigrations(p).filter(({ id }) => !applied.has(id));
92
+
93
+ await runInOrder(executor, toSteps(missing, `INSERT INTO ${p}migrations (id, applied_at) VALUES (?, UTC_TIMESTAMP(3))`));
94
+
95
+ return missing.map(({ id }) => id);
96
+ } finally {
97
+ await executor.query('SELECT RELEASE_LOCK(?)', [lockName]);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * @param {Executor} executor
103
+ * @param {string} p
104
+ * @param {number} lockTimeoutMs
105
+ * @returns {Promise<string[]>}
106
+ */
107
+ async function migratePostgres(executor, p, lockTimeoutMs) {
108
+ await executor.query('BEGIN', []);
109
+
110
+ try {
111
+ // SET LOCAL: until the end of the transaction, then the pooled connection gets its settings back
112
+ await executor.query('SET LOCAL statement_timeout = 0', []);
113
+ await executor.query(`SET LOCAL lock_timeout = ${Math.max(1, lockTimeoutMs)}`, []);
114
+
115
+ try {
116
+ await executor.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`${p}migrations`]);
117
+ } catch (error) {
118
+ if (/** @type {{ code?: string }} */ (error).code === '55P03') {
119
+ throw new MigrationLockTimeoutError(
120
+ `migrate: the lock of "${p}migrations" was not free in ${lockTimeoutMs} ms`,
121
+ { cause: error },
122
+ );
123
+ }
124
+
125
+ throw error;
126
+ }
127
+
128
+ await executor.query('SET LOCAL lock_timeout = 0', []);
129
+ await executor.query(postgresMigrationsTable(p), []);
130
+
131
+ const applied = await readApplied(executor, `${p}migrations`);
132
+ const missing = postgresMigrations(p).filter(({ id }) => !applied.has(id));
133
+
134
+ await runInOrder(executor, toSteps(missing, `INSERT INTO ${p}migrations (id, applied_at) VALUES ($1, clock_timestamp())`));
135
+
136
+ await executor.query('COMMIT', []);
137
+
138
+ return missing.map(({ id }) => id);
139
+ } catch (error) {
140
+ await executor.query('ROLLBACK', []);
141
+ throw error;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Applies the missing migrations under a DB lock, on a connection of its own, without a statement timeout.
147
+ * @template C
148
+ * @param {import('./types.js').Adapter<C>} adapter
149
+ * @param {string} tablePrefix Checked.
150
+ * @param {import('./types.js').MigrateOptions} [options]
151
+ * @returns {Promise<string[]>} The ids of the applied migrations.
152
+ */
153
+ export function runMigrations(adapter, tablePrefix, options = {}) {
154
+ const lockTimeoutMs = parseDuration(options.lockTimeout ?? '10m');
155
+
156
+ if (lockTimeoutMs === null) {
157
+ return Promise.reject(new InvalidOptionsError('migrate: "lockTimeout" must be a duration'));
158
+ }
159
+
160
+ return adapter.withConnection(executor => (adapter.dialect === 'mysql'
161
+ ? migrateMysql(executor, tablePrefix, lockTimeoutMs)
162
+ : migratePostgres(executor, tablePrefix, lockTimeoutMs)));
163
+ }
@@ -0,0 +1,34 @@
1
+ import { InvalidMutexKeyError } from './errors.js';
2
+ import { PRINTABLE_ID_REGEXP } from './validate.js';
3
+
4
+ export const MAX_MUTEX_KEYS = 10;
5
+ export const MAX_MUTEX_KEY_LENGTH = 255;
6
+
7
+ /**
8
+ * Checks the mutex keys of a job.
9
+ * @param {unknown} keys
10
+ * @param {string} where For the error message.
11
+ * @returns {string[]} The keys without duplicates, sorted (the lock order).
12
+ */
13
+ export function normalizeMutexKeys(keys, where) {
14
+ if (!Array.isArray(keys)) {
15
+ throw new InvalidMutexKeyError(`${where}: the mutex keys must be an array`);
16
+ }
17
+
18
+ for (const key of keys) {
19
+ if (typeof key !== 'string' || key.length > MAX_MUTEX_KEY_LENGTH || !PRINTABLE_ID_REGEXP.test(key)) {
20
+ throw new InvalidMutexKeyError(
21
+ `${where}: a mutex key has 1 to ${MAX_MUTEX_KEY_LENGTH} printable ASCII characters, without spaces at `
22
+ + `the ends: ${JSON.stringify(key)}`,
23
+ );
24
+ }
25
+ }
26
+
27
+ const unique = [...new Set(/** @type {string[]} */ (keys))].toSorted();
28
+
29
+ if (unique.length > MAX_MUTEX_KEYS) {
30
+ throw new InvalidMutexKeyError(`${where}: at most ${MAX_MUTEX_KEYS} mutex keys, got ${unique.length}`);
31
+ }
32
+
33
+ return unique;
34
+ }
package/src/new_job.js ADDED
@@ -0,0 +1,128 @@
1
+ import { monotonicFactory } from 'ulid';
2
+ import { parseDuration } from './duration.js';
3
+ import { InvalidOptionsError } from './errors.js';
4
+ import { normalizeMutexKeys } from './mutex_keys.js';
5
+ import { serializePayload } from './payload.js';
6
+ import {
7
+ checkFailureJob,
8
+ checkJobFields,
9
+ checkObject,
10
+ JOB_NAME_REGEXP,
11
+ PRINTABLE_ID_REGEXP,
12
+ } from './validate.js';
13
+
14
+ const ENQUEUE_OPTION_NAMES = [
15
+ 'jobId', 'delay', 'runAt', 'maxAttempts', 'maxStalls', 'retention', 'failureJob', 'mutexKeys',
16
+ ];
17
+ const MAX_JOB_ID_LENGTH = 120;
18
+ /** Sorted by creation order in this process, also within one millisecond */
19
+ const ulid = monotonicFactory();
20
+
21
+ /**
22
+ * A job to insert, with every value frozen at enqueue.
23
+ * @typedef {object} NewJob
24
+ * @property {string} id `<name>:<jobId>`, or a ULID.
25
+ * @property {boolean} hasJobId
26
+ * @property {string} name
27
+ * @property {string} payloadJson
28
+ * @property {readonly string[]} mutexKeys Sorted, without duplicates.
29
+ * @property {number} maxAttempts
30
+ * @property {number} maxStalls
31
+ * @property {number} completedRetentionMs
32
+ * @property {number} failedRetentionMs
33
+ * @property {string | null} failureJob
34
+ * @property {number} delayMs From the DB clock at insert. 0 with `runAt`.
35
+ * @property {Date | null} runAt
36
+ */
37
+
38
+ /**
39
+ * @typedef {object} NewJobParams
40
+ * @property {string} name
41
+ * @property {import('./types.js').JobDefinition | null} definition Null: an unknown name, the defaults only.
42
+ * @property {import('./defaults.js').ResolvedDefaults} defaults
43
+ * @property {unknown} payload
44
+ * @property {import('./types.js').EnqueueOptions | undefined} options
45
+ * @property {number} maxPayloadBytes
46
+ */
47
+
48
+ /**
49
+ * @param {unknown} jobId
50
+ * @param {string} where
51
+ */
52
+ function checkJobId(jobId, where) {
53
+ if (typeof jobId !== 'string' || jobId.length > MAX_JOB_ID_LENGTH || !PRINTABLE_ID_REGEXP.test(jobId)) {
54
+ throw new InvalidOptionsError(
55
+ `${where}: "jobId" must have 1 to ${MAX_JOB_ID_LENGTH} printable ASCII characters, without spaces at the `
56
+ + `ends: ${JSON.stringify(jobId)}`,
57
+ );
58
+ }
59
+ }
60
+
61
+ /**
62
+ * @param {Record<string, unknown>} options
63
+ * @param {string} where
64
+ * @returns {{ delayMs: number, runAt: Date | null }}
65
+ */
66
+ function getSchedule(options, where) {
67
+ if (options.delay !== undefined && options.runAt !== undefined) {
68
+ throw new InvalidOptionsError(`${where}: "delay" or "runAt", not both`);
69
+ }
70
+
71
+ if (options.runAt !== undefined) {
72
+ if (!(options.runAt instanceof Date) || Number.isNaN(options.runAt.getTime())) {
73
+ throw new InvalidOptionsError(`${where}: "runAt" must be a valid Date`);
74
+ }
75
+
76
+ return { delayMs: 0, runAt: options.runAt };
77
+ }
78
+
79
+ const delayMs = options.delay === undefined ? 0 : parseDuration(options.delay);
80
+
81
+ if (delayMs === null) {
82
+ throw new InvalidOptionsError(`${where}: "delay" must be a duration, got ${JSON.stringify(options.delay)}`);
83
+ }
84
+
85
+ return { delayMs, runAt: null };
86
+ }
87
+
88
+ /**
89
+ * Checks an enqueue and computes the job to insert: options, then the definition, then the defaults.
90
+ * @param {NewJobParams} params
91
+ * @returns {NewJob}
92
+ */
93
+ export function buildNewJob({ name, definition, defaults, payload, options = {}, maxPayloadBytes }) {
94
+ const where = `enqueue(${JSON.stringify(name)})`;
95
+
96
+ if (typeof name !== 'string' || !JOB_NAME_REGEXP.test(name)) {
97
+ throw new InvalidOptionsError(`${where}: the job name must match ${JOB_NAME_REGEXP}`);
98
+ }
99
+
100
+ const scope = { where, ErrorClass: InvalidOptionsError };
101
+
102
+ checkObject(options, ENQUEUE_OPTION_NAMES, 'options', scope);
103
+ checkJobFields(options, scope);
104
+
105
+ if (options.jobId !== undefined) {
106
+ checkJobId(options.jobId, where);
107
+ }
108
+
109
+ const failureJob = options.failureJob ?? definition?.failureJob;
110
+ const payloadJson = serializePayload(payload, maxPayloadBytes, where);
111
+ // The keys come from the payload as the handler gets it: as JSON gives it back.
112
+ const rawKeys = options.mutexKeys ?? definition?.mutexKeys?.(JSON.parse(payloadJson)) ?? [];
113
+ const retention = { ...definition?.retention, ...options.retention };
114
+
115
+ return {
116
+ id: options.jobId === undefined ? ulid() : `${name}:${options.jobId}`,
117
+ hasJobId: options.jobId !== undefined,
118
+ name,
119
+ payloadJson,
120
+ mutexKeys: normalizeMutexKeys(rawKeys, where),
121
+ maxAttempts: options.maxAttempts ?? definition?.maxAttempts ?? defaults.maxAttempts,
122
+ maxStalls: options.maxStalls ?? definition?.maxStalls ?? defaults.maxStalls,
123
+ completedRetentionMs: parseDuration(retention.completed) ?? defaults.retention.completedMs,
124
+ failedRetentionMs: parseDuration(retention.failed) ?? defaults.retention.failedMs,
125
+ failureJob: failureJob === undefined ? null : checkFailureJob(failureJob, name, scope),
126
+ ...getSchedule(options, where),
127
+ };
128
+ }
package/src/payload.js ADDED
@@ -0,0 +1,54 @@
1
+ import { InvalidOptionsError, PayloadTooLargeError } from './errors.js';
2
+
3
+ export const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024;
4
+
5
+ /**
6
+ * Serializes a payload: JSON, well-formed strings (MySQL refuses a lone surrogate), no NUL character (PostgreSQL
7
+ * refuses it), at most maxBytes.
8
+ * @param {unknown} payload
9
+ * @param {number} maxBytes
10
+ * @param {string} where For the error message.
11
+ * @returns {string} The JSON.
12
+ */
13
+ export function serializePayload(payload, maxBytes, where) {
14
+ let wellFormed = true;
15
+ let hasNul = false;
16
+ let json;
17
+
18
+ try {
19
+ json = JSON.stringify(payload, (key, value) => {
20
+ if (!key.isWellFormed() || (typeof value === 'string' && !value.isWellFormed())) {
21
+ wellFormed = false;
22
+ }
23
+
24
+ if (key.includes('\0') || (typeof value === 'string' && value.includes('\0'))) {
25
+ hasNul = true;
26
+ }
27
+
28
+ return value;
29
+ });
30
+ } catch (error) {
31
+ throw new InvalidOptionsError(`${where}: the payload is not JSON`, { cause: error });
32
+ }
33
+
34
+ if (json === undefined) {
35
+ throw new InvalidOptionsError(`${where}: the payload is not JSON`);
36
+ }
37
+
38
+ if (!wellFormed) {
39
+ throw new InvalidOptionsError(`${where}: the payload has a string that is not well-formed UTF-16`);
40
+ }
41
+
42
+ if (hasNul) {
43
+ // PostgreSQL jsonb refuses it: refused on both databases, for the same behavior
44
+ throw new InvalidOptionsError(`${where}: the payload has a NUL character (\\u0000)`);
45
+ }
46
+
47
+ const bytes = Buffer.byteLength(json);
48
+
49
+ if (bytes > maxBytes) {
50
+ throw new PayloadTooLargeError(`${where}: the payload has ${bytes} bytes of JSON, at most ${maxBytes}`);
51
+ }
52
+
53
+ return json;
54
+ }
@@ -0,0 +1,169 @@
1
+ // The handlers of an instance, their lanes and the known definitions.
2
+ import { parseDuration } from './duration.js';
3
+ import { ConfigError } from './errors.js';
4
+ import { checkDuration, checkInteger, checkObject, isPlainObject, JOB_NAME_REGEXP } from './validate.js';
5
+
6
+ /** @typedef {import('./types.js').JobDefinition} JobDefinition */
7
+
8
+ /**
9
+ * A concurrency limit in this process.
10
+ * @typedef {object} Lane
11
+ * @property {string} name
12
+ * @property {number} concurrency
13
+ * @property {number} running
14
+ */
15
+
16
+ /**
17
+ * @typedef {object} HandlerEntry
18
+ * @property {string} name
19
+ * @property {JobDefinition | null} definition
20
+ * @property {import('./types.js').Handler} handler
21
+ * @property {Lane} lane
22
+ * @property {number | null} timeoutMs Resolved: the handle() options, else the definition, else the defaults.
23
+ */
24
+
25
+ /**
26
+ * @typedef {object} Registry
27
+ * @property {(job: JobDefinition | string, handler: import('./types.js').Handler, options?: object) => void} handle
28
+ * @property {(name: string) => HandlerEntry | undefined} getEntry
29
+ * @property {() => HandlerEntry[]} entries
30
+ * @property {(name: string) => JobDefinition | null} findDefinition In `jobs`, else handled.
31
+ * @property {() => string[]} keyedNames The handled names whose definition has mutex keys.
32
+ * @property {() => void} validate Checks the handlers together: each failure job resolves and has none of its own.
33
+ */
34
+
35
+ /**
36
+ * @param {object} params
37
+ * @param {Map<string, JobDefinition>} params.jobsByName
38
+ * @param {unknown} params.lanes The `lanes` option.
39
+ * @param {number} params.concurrency The process concurrency.
40
+ * @param {number | null} params.defaultTimeoutMs
41
+ * @returns {Registry}
42
+ */
43
+ export function createRegistry({ jobsByName, lanes, concurrency, defaultTimeoutMs }) {
44
+ const scope = { where: 'createSqlane', ErrorClass: ConfigError };
45
+ /** @type {Map<string, Lane>} */
46
+ const namedLanes = new Map();
47
+ /** The handlers without a lane: the process concurrency only */
48
+ const defaultLane = { name: '', concurrency, running: 0 };
49
+ /** @type {Map<string, HandlerEntry>} */
50
+ const entriesByName = new Map();
51
+
52
+ if (lanes !== undefined && !isPlainObject(lanes)) {
53
+ throw new ConfigError('createSqlane: "lanes" must be an object of lane options');
54
+ }
55
+
56
+ for (const [name, options] of Object.entries(lanes ?? {})) {
57
+ const laneOptions = checkObject(options, ['concurrency'], `lane "${name}" options`, scope);
58
+
59
+ checkInteger(laneOptions.concurrency, `lane "${name}" concurrency`, scope);
60
+ namedLanes.set(name, { name, concurrency: Math.min(Number(laneOptions.concurrency), concurrency), running: 0 });
61
+ }
62
+
63
+ /** @type {Registry['findDefinition']} */
64
+ function findDefinition(name) {
65
+ return jobsByName.get(name) ?? entriesByName.get(name)?.definition ?? null;
66
+ }
67
+
68
+ /** @type {Registry['handle']} */
69
+ function handle(job, handler, options = {}) {
70
+ const name = isPlainObject(job) ? job.name : job;
71
+ const where = `handle(${JSON.stringify(name)})`;
72
+ const handleScope = { where, ErrorClass: ConfigError };
73
+
74
+ if (typeof name !== 'string' || !JOB_NAME_REGEXP.test(name)) {
75
+ throw new ConfigError(`${where}: the job must be a job definition or a job name`);
76
+ }
77
+
78
+ if (typeof handler !== 'function') {
79
+ throw new ConfigError(`${where}: the handler must be a function`);
80
+ }
81
+
82
+ const handleOptions = checkObject(options, ['concurrency', 'lane', 'timeout'], 'options', handleScope);
83
+
84
+ if (entriesByName.has(name)) {
85
+ throw new ConfigError(`${where}: the job is handled already`);
86
+ }
87
+
88
+ const known = jobsByName.get(name);
89
+ const definition = isPlainObject(job) ? /** @type {JobDefinition} */ (job) : known ?? null;
90
+
91
+ if (known && definition !== known) {
92
+ throw new ConfigError(`${where}: another definition of "${name}" is in "jobs"`);
93
+ }
94
+
95
+ if (handleOptions.lane !== undefined && handleOptions.concurrency !== undefined) {
96
+ throw new ConfigError(`${where}: "lane" or "concurrency", not both`);
97
+ }
98
+
99
+ /** @type {Lane} */
100
+ let lane = defaultLane;
101
+
102
+ if (handleOptions.lane !== undefined) {
103
+ const named = namedLanes.get(String(handleOptions.lane));
104
+
105
+ if (!named) {
106
+ throw new ConfigError(`${where}: unknown lane ${JSON.stringify(handleOptions.lane)}`);
107
+ }
108
+
109
+ lane = named;
110
+ } else if (handleOptions.concurrency !== undefined) {
111
+ checkInteger(handleOptions.concurrency, '"concurrency"', handleScope);
112
+ lane = {
113
+ name: `handler:${name}`,
114
+ concurrency: Math.min(Number(handleOptions.concurrency), concurrency),
115
+ running: 0,
116
+ };
117
+ }
118
+
119
+ if (handleOptions.timeout !== undefined) {
120
+ checkDuration(handleOptions.timeout, '"timeout"', handleScope, true);
121
+ }
122
+
123
+ const timeoutMs = parseDuration(handleOptions.timeout ?? definition?.timeout) ?? defaultTimeoutMs;
124
+
125
+ if (definition?.mutexKeys && timeoutMs === null) {
126
+ throw new ConfigError(
127
+ `${where}: a job with mutex keys needs a timeout (in handle(), the definition or "defaults")`,
128
+ );
129
+ }
130
+
131
+ entriesByName.set(name, { name, definition, handler, lane, timeoutMs });
132
+ }
133
+
134
+ /** @type {Registry['validate']} */
135
+ function validate() {
136
+ for (const entry of entriesByName.values()) {
137
+ const failureJob = entry.definition?.failureJob;
138
+
139
+ if (failureJob === undefined) {
140
+ continue;
141
+ }
142
+
143
+ const where = `handle(${JSON.stringify(entry.name)})`;
144
+ const known = typeof failureJob !== 'string' || jobsByName.has(failureJob) || entriesByName.has(failureJob);
145
+
146
+ if (!known) {
147
+ throw new ConfigError(`${where}: the failure job "${failureJob}" is not in "jobs" and not handled`);
148
+ }
149
+
150
+ // Handled by name only: no definition, so no failure job of its own
151
+ const failureDefinition = typeof failureJob === 'string' ? findDefinition(failureJob) : failureJob;
152
+
153
+ if (failureDefinition?.failureJob !== undefined) {
154
+ throw new ConfigError(`${where}: the failure job "${failureDefinition?.name}" has a failure job of its own`);
155
+ }
156
+ }
157
+ }
158
+
159
+ return {
160
+ handle,
161
+ getEntry: name => entriesByName.get(name),
162
+ entries: () => [...entriesByName.values()],
163
+ findDefinition,
164
+ keyedNames: () => [...entriesByName.values()]
165
+ .filter(entry => entry.definition?.mutexKeys)
166
+ .map(entry => entry.name),
167
+ validate,
168
+ };
169
+ }