@openstax/ts-utils 1.27.5 → 1.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/cjs/errors/index.d.ts +11 -0
  2. package/dist/cjs/errors/index.js +15 -1
  3. package/dist/cjs/fetch/fetchStatusRetry.d.ts +1 -0
  4. package/dist/cjs/fetch/fetchStatusRetry.js +18 -7
  5. package/dist/cjs/guards/index.d.ts +8 -0
  6. package/dist/cjs/guards/index.js +10 -1
  7. package/dist/cjs/services/documentStore/unversioned/dynamodb.d.ts +9 -0
  8. package/dist/cjs/services/documentStore/unversioned/dynamodb.js +67 -0
  9. package/dist/cjs/services/documentStore/unversioned/file-system.d.ts +12 -1
  10. package/dist/cjs/services/documentStore/unversioned/file-system.js +49 -1
  11. package/dist/cjs/services/searchProvider/index.d.ts +2 -0
  12. package/dist/cjs/services/searchProvider/memorySearchTheBadWay.js +2 -0
  13. package/dist/cjs/services/searchProvider/openSearch.js +3 -0
  14. package/dist/cjs/tsconfig.without-specs.cjs.tsbuildinfo +1 -1
  15. package/dist/esm/errors/index.d.ts +11 -0
  16. package/dist/esm/errors/index.js +13 -0
  17. package/dist/esm/fetch/fetchStatusRetry.d.ts +1 -0
  18. package/dist/esm/fetch/fetchStatusRetry.js +18 -7
  19. package/dist/esm/guards/index.d.ts +8 -0
  20. package/dist/esm/guards/index.js +8 -0
  21. package/dist/esm/services/documentStore/unversioned/dynamodb.d.ts +9 -0
  22. package/dist/esm/services/documentStore/unversioned/dynamodb.js +65 -1
  23. package/dist/esm/services/documentStore/unversioned/file-system.d.ts +12 -1
  24. package/dist/esm/services/documentStore/unversioned/file-system.js +50 -2
  25. package/dist/esm/services/searchProvider/index.d.ts +2 -0
  26. package/dist/esm/services/searchProvider/memorySearchTheBadWay.js +2 -0
  27. package/dist/esm/services/searchProvider/openSearch.js +3 -0
  28. package/dist/esm/tsconfig.without-specs.esm.tsbuildinfo +1 -1
  29. package/package.json +3 -1
@@ -75,3 +75,14 @@ export declare class SessionExpiredError extends Error {
75
75
  static matches: (e: any) => e is typeof SessionExpiredError;
76
76
  constructor(message?: string);
77
77
  }
78
+ /**
79
+ * Conflict error
80
+ *
81
+ * `ConflictError.matches(error)` is a reliable way to check if an error is a
82
+ * `ConflictError`; `instanceof` checks may not work if code is split into multiple bundles
83
+ */
84
+ export declare class ConflictError extends Error {
85
+ static readonly TYPE = "ConflictError";
86
+ static matches: (e: any) => e is typeof ConflictError;
87
+ constructor(message?: string);
88
+ }
@@ -97,3 +97,16 @@ export class SessionExpiredError extends Error {
97
97
  }
98
98
  SessionExpiredError.TYPE = 'SessionExpiredError';
99
99
  SessionExpiredError.matches = errorIsType(SessionExpiredError);
100
+ /**
101
+ * Conflict error
102
+ *
103
+ * `ConflictError.matches(error)` is a reliable way to check if an error is a
104
+ * `ConflictError`; `instanceof` checks may not work if code is split into multiple bundles
105
+ */
106
+ export class ConflictError extends Error {
107
+ constructor(message) {
108
+ super(message || ConflictError.TYPE);
109
+ }
110
+ }
111
+ ConflictError.TYPE = 'ConflictError';
112
+ ConflictError.matches = errorIsType(ConflictError);
@@ -2,6 +2,7 @@ import { RetryOptions } from '../misc/helpers';
2
2
  import { GenericFetch } from '.';
3
3
  interface Options extends RetryOptions {
4
4
  status?: number[];
5
+ timeout?: number;
5
6
  }
6
7
  export declare const fetchStatusRetry: (base: GenericFetch, options: Options) => GenericFetch;
7
8
  export {};
@@ -1,12 +1,23 @@
1
1
  import { retryWithDelay } from '../misc/helpers';
2
2
  export const fetchStatusRetry = (base, options) => {
3
- return (...params) => retryWithDelay(() => base(...params).then(r => {
4
- var _a;
5
- if ((_a = options.status) === null || _a === void 0 ? void 0 : _a.includes(r.status)) {
6
- return r.text().then(text => {
7
- throw new Error(`fetch failed ${params[0]} -- ${r.status}: ${text}`);
3
+ return (...params) => retryWithDelay(() => {
4
+ const fetchPromise = base(...params).then(r => {
5
+ var _a;
6
+ if ((_a = options.status) === null || _a === void 0 ? void 0 : _a.includes(r.status)) {
7
+ return r.text().then(text => {
8
+ throw new Error(`fetch failed ${params[0]} -- ${r.status}: ${text}`);
9
+ });
10
+ }
11
+ return r;
12
+ });
13
+ if (options.timeout) {
14
+ const timeoutPromise = new Promise((_, reject) => {
15
+ setTimeout(() => {
16
+ reject(new Error(`fetch timeout after ${options.timeout}ms: ${params[0]}`));
17
+ }, options.timeout);
8
18
  });
19
+ return Promise.race([fetchPromise, timeoutPromise]);
9
20
  }
10
- return r;
11
- }), options);
21
+ return fetchPromise;
22
+ }, options);
12
23
  };
@@ -6,6 +6,14 @@
6
6
  * `const result = (array as Array<string | undefined>).filter(isDefined);`
7
7
  */
8
8
  export declare const isDefined: <X>(x: X) => x is Exclude<X, undefined>;
9
+ /**
10
+ * checks if a thing is not null. while often easy to do with a simple if statement,
11
+ * in certain situations the guard is required and its nice to have one pre-defined.
12
+ * E.g. in the following example, the result is `Array<string>`.
13
+ *
14
+ * `const result = (array as Array<string | null>).filter(isNotNull);`
15
+ */
16
+ export declare const isNotNull: <X>(x: X) => x is Exclude<X, null>;
9
17
  /**
10
18
  * checks if a thing is a number. while often easy to do with a simple if statement, in certain
11
19
  * situations the guard is required and its nice to have one pre-defined. E.g. in the following
@@ -6,6 +6,14 @@
6
6
  * `const result = (array as Array<string | undefined>).filter(isDefined);`
7
7
  */
8
8
  export const isDefined = (x) => x !== undefined;
9
+ /**
10
+ * checks if a thing is not null. while often easy to do with a simple if statement,
11
+ * in certain situations the guard is required and its nice to have one pre-defined.
12
+ * E.g. in the following example, the result is `Array<string>`.
13
+ *
14
+ * `const result = (array as Array<string | null>).filter(isNotNull);`
15
+ */
16
+ export const isNotNull = (x) => x !== null;
9
17
  /**
10
18
  * checks if a thing is a number. while often easy to do with a simple if statement, in certain
11
19
  * situations the guard is required and its nice to have one pre-defined. E.g. in the following
@@ -9,6 +9,7 @@ export declare const dynamoUnversionedDocumentStore: <C extends string = "dynamo
9
9
  tableName: import("../../../config").ConfigValueProvider<string>;
10
10
  }; }) => <K extends keyof T>(_: {}, hashKey: K, options?: {
11
11
  afterWrite?: ((item: T) => void | Promise<void>) | undefined;
12
+ batchAfterWrite?: ((items: T[]) => void | Promise<void>) | undefined;
12
13
  } | undefined) => {
13
14
  loadAllDocumentsTheBadWay: () => Promise<T[]>;
14
15
  getItemsByField: (key: keyof T, value: T[K], pageKey?: string | undefined) => Promise<{
@@ -20,5 +21,13 @@ export declare const dynamoUnversionedDocumentStore: <C extends string = "dynamo
20
21
  incrementItemAttribute: (id: T[K], attribute: keyof T) => Promise<number>;
21
22
  patchItem: (item: Partial<T>) => Promise<T>;
22
23
  putItem: (item: T) => Promise<T>;
24
+ createItem: (item: T) => Promise<T>;
25
+ batchCreateItem: (items: T[], concurrency?: number) => Promise<{
26
+ successful: T[];
27
+ failed: {
28
+ item: T;
29
+ error: Error;
30
+ }[];
31
+ }>;
23
32
  };
24
33
  export {};
@@ -1,7 +1,8 @@
1
1
  import { BatchGetItemCommand, DynamoDB, GetItemCommand, PutItemCommand, QueryCommand, ScanCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
2
+ import asyncPool from 'tiny-async-pool';
2
3
  import { once } from '../../..';
3
4
  import { resolveConfigValue } from '../../../config';
4
- import { NotFoundError } from '../../../errors';
5
+ import { ConflictError, NotFoundError } from '../../../errors';
5
6
  import { ifDefined } from '../../../guards';
6
7
  import { decodeDynamoDocument, encodeDynamoAttribute, encodeDynamoDocument } from '../dynamoEncoding';
7
8
  export const dynamoUnversionedDocumentStore = (initializer) => {
@@ -157,6 +158,69 @@ export const dynamoUnversionedDocumentStore = (initializer) => {
157
158
  await ((_a = options === null || options === void 0 ? void 0 : options.afterWrite) === null || _a === void 0 ? void 0 : _a.call(options, updatedDoc));
158
159
  return updatedDoc;
159
160
  },
161
+ /* creates a new document, fails if the primary key already exists */
162
+ createItem: async (item) => {
163
+ const cmd = new PutItemCommand({
164
+ TableName: await tableName(),
165
+ Item: encodeDynamoDocument(item),
166
+ ConditionExpression: 'attribute_not_exists(#k)',
167
+ ExpressionAttributeNames: {
168
+ '#k': hashKey.toString(),
169
+ },
170
+ });
171
+ return await dynamodb().send(cmd).then(() => item).catch((error) => {
172
+ throw error.name === 'ConditionalCheckFailedException' ?
173
+ new ConflictError(`Item with ${String(hashKey)} "${item[hashKey]}" already exists`) : error;
174
+ }).then(async (createdDoc) => {
175
+ var _a;
176
+ await ((_a = options === null || options === void 0 ? void 0 : options.afterWrite) === null || _a === void 0 ? void 0 : _a.call(options, createdDoc));
177
+ return createdDoc;
178
+ });
179
+ },
180
+ /* creates multiple new documents, returns successful and failed items separately */
181
+ batchCreateItem: async (items, concurrency = 10) => {
182
+ if (items.length === 0)
183
+ return { successful: [], failed: [] };
184
+ const successful = [];
185
+ const failed = [];
186
+ // Process using async iterator (tiny-async-pool v2)
187
+ for await (const result of asyncPool(concurrency, items, async (item) => {
188
+ var _a;
189
+ try {
190
+ const cmd = new PutItemCommand({
191
+ TableName: await tableName(),
192
+ Item: encodeDynamoDocument(item),
193
+ ConditionExpression: 'attribute_not_exists(#k)',
194
+ ExpressionAttributeNames: {
195
+ '#k': hashKey.toString(),
196
+ },
197
+ });
198
+ await dynamodb().send(cmd);
199
+ // Only call individual afterWrite if batchAfterWrite is not defined
200
+ if (!(options === null || options === void 0 ? void 0 : options.batchAfterWrite)) {
201
+ await ((_a = options === null || options === void 0 ? void 0 : options.afterWrite) === null || _a === void 0 ? void 0 : _a.call(options, item));
202
+ }
203
+ return { success: true, item };
204
+ }
205
+ catch (error) {
206
+ const processedError = error.name === 'ConditionalCheckFailedException' ?
207
+ new ConflictError(`Item with ${String(hashKey)} "${item[hashKey]}" already exists`) : error;
208
+ return { success: false, item, error: processedError };
209
+ }
210
+ })) {
211
+ if (result.success) {
212
+ successful.push(result.item);
213
+ }
214
+ else {
215
+ failed.push({ item: result.item, error: result.error });
216
+ }
217
+ }
218
+ // Call batchAfterWrite with all successful items if defined
219
+ if ((options === null || options === void 0 ? void 0 : options.batchAfterWrite) && successful.length > 0) {
220
+ await options.batchAfterWrite(successful);
221
+ }
222
+ return { successful, failed };
223
+ },
160
224
  };
161
225
  };
162
226
  };
@@ -7,7 +7,10 @@ interface Initializer<C> {
7
7
  }
8
8
  export declare const fileSystemUnversionedDocumentStore: <C extends string = "fileSystem">(initializer: Initializer<C>) => <T extends TDocument<T>>() => (configProvider: { [key in C]: {
9
9
  tableName: import("../../../config").ConfigValueProvider<string>;
10
- }; }) => <K extends keyof T>(_: {}, hashKey: K) => {
10
+ }; }) => <K extends keyof T>(_: {}, hashKey: K, options?: {
11
+ afterWrite?: ((item: T) => void | Promise<void>) | undefined;
12
+ batchAfterWrite?: ((items: T[]) => void | Promise<void>) | undefined;
13
+ } | undefined) => {
11
14
  loadAllDocumentsTheBadWay: () => Promise<T[]>;
12
15
  getItemsByField: (key: keyof T, value: T[K], pageKey?: string | undefined) => Promise<{
13
16
  items: T[];
@@ -18,5 +21,13 @@ export declare const fileSystemUnversionedDocumentStore: <C extends string = "fi
18
21
  incrementItemAttribute: (id: T[K], attribute: keyof T) => Promise<number>;
19
22
  patchItem: (item: Partial<T>) => Promise<T>;
20
23
  putItem: (item: T) => Promise<T>;
24
+ createItem: (item: T) => Promise<T>;
25
+ batchCreateItem: (items: T[], _concurrency?: number) => Promise<{
26
+ successful: T[];
27
+ failed: {
28
+ item: T;
29
+ error: Error;
30
+ }[];
31
+ }>;
21
32
  };
22
33
  export {};
@@ -2,9 +2,9 @@ import * as fsModule from 'fs';
2
2
  import path from 'path';
3
3
  import { hashValue } from '../../..';
4
4
  import { resolveConfigValue } from '../../../config';
5
- import { NotFoundError } from '../../../errors';
5
+ import { ConflictError, NotFoundError } from '../../../errors';
6
6
  import { ifDefined, isDefined } from '../../../guards';
7
- export const fileSystemUnversionedDocumentStore = (initializer) => () => (configProvider) => (_, hashKey) => {
7
+ export const fileSystemUnversionedDocumentStore = (initializer) => () => (configProvider) => (_, hashKey, options) => {
8
8
  const tableName = resolveConfigValue(configProvider[initializer.configSpace || 'fileSystem'].tableName);
9
9
  const tablePath = tableName.then((table) => path.join(initializer.dataDir, table));
10
10
  const { mkdir, readdir, readFile, writeFile } = ifDefined(initializer.fs, fsModule);
@@ -99,5 +99,53 @@ export const fileSystemUnversionedDocumentStore = (initializer) => () => (config
99
99
  writeFile(path, JSON.stringify(item, null, 2), (err) => err ? reject(err) : resolve(item));
100
100
  });
101
101
  },
102
+ createItem: async (item) => {
103
+ const hashed = hashFilename(item[hashKey]);
104
+ const existingItem = await load(hashed);
105
+ if (existingItem) {
106
+ throw new ConflictError(`Item with ${String(hashKey)} "${item[hashKey]}" already exists`);
107
+ }
108
+ const path = await filePath(hashed);
109
+ await mkTableDir;
110
+ return new Promise((resolve, reject) => {
111
+ writeFile(path, JSON.stringify(item, null, 2), (err) => err ? reject(err) : resolve(item));
112
+ });
113
+ },
114
+ batchCreateItem: async (items, _concurrency = 1) => {
115
+ var _a;
116
+ if (items.length === 0)
117
+ return { successful: [], failed: [] };
118
+ const successful = [];
119
+ const failed = [];
120
+ // Process items sequentially to ensure consistent conflict detection
121
+ // Note: concurrency parameter is ignored for filesystem to avoid race conditions
122
+ for (const item of items) {
123
+ try {
124
+ const hashed = hashFilename(item[hashKey]);
125
+ const existingItem = await load(hashed);
126
+ if (existingItem) {
127
+ throw new ConflictError(`Item with ${String(hashKey)} "${item[hashKey]}" already exists`);
128
+ }
129
+ const path = await filePath(hashed);
130
+ await mkTableDir;
131
+ const createdItem = await new Promise((resolve, reject) => {
132
+ writeFile(path, JSON.stringify(item, null, 2), (err) => err ? reject(err) : resolve(item));
133
+ });
134
+ successful.push(createdItem);
135
+ // Only call individual afterWrite if batchAfterWrite is not defined
136
+ if (!(options === null || options === void 0 ? void 0 : options.batchAfterWrite)) {
137
+ await ((_a = options === null || options === void 0 ? void 0 : options.afterWrite) === null || _a === void 0 ? void 0 : _a.call(options, createdItem));
138
+ }
139
+ }
140
+ catch (error) {
141
+ failed.push({ item, error: error });
142
+ }
143
+ }
144
+ // Call batchAfterWrite with all successful items if defined
145
+ if ((options === null || options === void 0 ? void 0 : options.batchAfterWrite) && successful.length > 0) {
146
+ await options.batchAfterWrite(successful);
147
+ }
148
+ return { successful, failed };
149
+ },
102
150
  };
103
151
  };
@@ -1,6 +1,8 @@
1
1
  export declare type FieldType = string | string[] | number | boolean;
2
2
  export declare type ESFilter = {
3
3
  terms: Record<string, FieldType>;
4
+ } | {
5
+ term: Record<string, FieldType>;
4
6
  } | {
5
7
  exists: {
6
8
  field: string;
@@ -84,6 +84,8 @@ const matchClause = (context, clause) => {
84
84
  : clause;
85
85
  if ('terms' in filter)
86
86
  return matchTerms(context, filter.terms);
87
+ else if ('term' in filter)
88
+ return matchTerms(context, filter.term);
87
89
  else if ('exists' in filter)
88
90
  return matchExists(context, filter.exists);
89
91
  else if ('bool' in filter)
@@ -23,6 +23,9 @@ export const openSearchService = (initializer = {}) => (configProvider) => {
23
23
  region: await resolveConfigValue(config.region),
24
24
  service: 'es',
25
25
  }),
26
+ maxRetries: 3,
27
+ requestTimeout: 3000,
28
+ pingTimeout: 1000,
26
29
  node: await resolveConfigValue(config.node),
27
30
  }));
28
31
  return (indexConfig) => {