terrier-engine 4.0.3

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 (4) hide show
  1. package/README.md +3 -0
  2. package/api.ts +112 -0
  3. package/db-client.ts +389 -0
  4. package/package.json +22 -0
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # Terrier Engine
2
+
3
+ This package is meant to be used in conjunction with the [Terrier Rails Engine](https://github.com/Terrier-Tech/terrier-engine).
package/api.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { Logger } from "tuff-core/logging"
2
+ import { QueryParams } from "tuff-core/urls"
3
+
4
+ const log = new Logger('Api')
5
+ log.level = 'debug'
6
+
7
+ /**
8
+ * All API responses containing these fields.
9
+ */
10
+ export type ApiResponse = {
11
+ status: 'success' | 'error'
12
+ message: string
13
+ }
14
+
15
+ /**
16
+ * Exception that gets thrown when an API call fails.
17
+ */
18
+ export class ApiException extends Error {
19
+ constructor(message: string) {
20
+ super(`ApiException: ${message}`)
21
+ }
22
+ }
23
+
24
+ async function request<ResponseType>(url: string, config: RequestInit): Promise<ResponseType> {
25
+ const response = await fetch(url, config)
26
+ return await response.json()
27
+ }
28
+
29
+ async function apiRequest<ResponseType>(url: string, config: RequestInit): Promise<ApiResponse & ResponseType> {
30
+ const response = await request<{ status?: unknown, message?: string } & ResponseType>(url, config)
31
+
32
+ if (response.status && typeof response.status == 'string' && (response.status == 'success' || response.status == 'error')) {
33
+ return response as ApiResponse & ResponseType
34
+ } else {
35
+ // If response.status does not exist or is not a string, e.g. 200, 404, etc.
36
+ throw new ApiException(response.message ?? "Unknown API Exception")
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Performs a GET request for the given datatype.
42
+ * This will only return if the response status=success, it will throw on error.
43
+ * `ResponseType` does not need to include the `status` or `message` fields, this is handled automatically.
44
+ * @param url the base URL for the request
45
+ * @param params a set of parameters that will be added to the URL as a query string
46
+ */
47
+ async function safeGet<ResponseType>(url: string, params: QueryParams | Record<string, string | undefined>): Promise<ResponseType> {
48
+ if (!params.raw) {
49
+ params = new QueryParams(params as Record<string, string>)
50
+ }
51
+ const fullUrl = (params as QueryParams).serialize(url)
52
+ log.debug(`Safe getting ${fullUrl}`)
53
+ const response = await apiRequest<ResponseType>(fullUrl, {
54
+ method: 'GET',
55
+ headers: {
56
+ 'Accept': 'application/json'
57
+ }
58
+ })
59
+ if (response.status == 'error') {
60
+ throw new ApiException(response.message)
61
+ }
62
+ return response
63
+ }
64
+
65
+ /**
66
+ * Performs a POST API request for the given data type.
67
+ * This will only return if the response status=success, it will throw on error.
68
+ * `ResponseType` does not need to include the `status` or `message` fields, this is handled automatically.
69
+ * @param url the URL of the API endpoint
70
+ * @param body the body of the request (will be transmitted as JSON)
71
+ */
72
+ async function safePost<ResponseType>(url: string, body: Record<string, unknown>): Promise<ResponseType> {
73
+ log.debug(`Safe posting to ${url} with body`, body)
74
+ const response = await apiRequest<ResponseType>(url, {
75
+ method: 'POST',
76
+ headers: {
77
+ 'Content-Type': 'application/json'
78
+ },
79
+ body: JSON.stringify(body)
80
+ })
81
+ if (response.status == 'error') {
82
+ throw new ApiException(response.message)
83
+ }
84
+ return response
85
+ }
86
+
87
+ /**
88
+ * Performs a POST API request for the given data type.
89
+ * Unlike `safePost`, this will return regardless of the status of the request.
90
+ * The result will automatically include ApiResponse.
91
+ * @param url the URL of the API endpoint
92
+ * @param body the body of the request (will be transmitted as JSON)
93
+ */
94
+ async function post<ResponseType>(url: string, body: Record<string, unknown> | FormData): Promise<ResponseType & ApiResponse> {
95
+ log.debug(`Posting to ${url} with body`, body)
96
+ const config = { method: 'POST' } as RequestInit
97
+ if (body instanceof FormData) {
98
+ config.body = body
99
+ } else {
100
+ config.body = JSON.stringify(body)
101
+ config.headers = { 'Content-Type': 'application/json' }
102
+ }
103
+ return await request<ResponseType & ApiResponse>(url, config)
104
+ }
105
+
106
+
107
+ const Api = {
108
+ safeGet,
109
+ safePost,
110
+ post
111
+ }
112
+ export default Api
package/db-client.ts ADDED
@@ -0,0 +1,389 @@
1
+ // noinspection JSUnusedGlobalSymbols
2
+
3
+ import {Logger} from "tuff-core/logging"
4
+ import Api, {ApiResponse} from "./api"
5
+
6
+ const log = new Logger('Db')
7
+ log.level = 'debug'
8
+
9
+ /**
10
+ * Type that maps keys to other types.
11
+ */
12
+ type ModelTypeMap = {
13
+ [name: string]: any
14
+ }
15
+
16
+ type ModelIncludesMap<M extends ModelTypeMap> = Record<keyof M, any>
17
+
18
+ /**
19
+ * Map of columns to values for the given model type.
20
+ */
21
+ type WhereMap<M> = {
22
+ [col in keyof M]?: unknown
23
+ }
24
+
25
+ /**
26
+ * A raw SQL where clause with an arbitrary number of additional arguments.
27
+ */
28
+ type WhereClause = {
29
+ clause: string,
30
+ args: unknown[]
31
+ }
32
+
33
+ /**
34
+ * Generic type for a get response.
35
+ */
36
+ type DbGetResponse<T> = {
37
+ records: T[]
38
+ }
39
+
40
+ /**
41
+ * Type for a count response.
42
+ */
43
+ type DbCountResponse = {
44
+ count: number
45
+ }
46
+
47
+ /**
48
+ * Describes one or more associations to include (same as ActiveRecord `includes`).
49
+ */
50
+ export type Includes<M extends ModelTypeMap, T extends keyof M, I extends ModelIncludesMap<M>> = {
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ [rel in I[T]]?: Includes<M,any,any>
53
+ }
54
+
55
+ /**
56
+ * Constructs an ActiveRecord query on the client side and executes it on the server.
57
+ */
58
+ class ModelQuery<PM extends ModelTypeMap, T extends keyof PM & string, I extends ModelIncludesMap<PM>> {
59
+
60
+ constructor(readonly modelType: T) {
61
+ }
62
+
63
+ private whereMaps = Array<WhereMap<PM[T]>>()
64
+ private whereClauses = Array<WhereClause>()
65
+
66
+ /**
67
+ * Adds one or more filters to the query.
68
+ * @param map a map of columns to scalar values.
69
+ */
70
+ where(map: WhereMap<PM[T]>): ModelQuery<PM,T,I>
71
+
72
+ /**
73
+ * Adds one or more filters to the query.
74
+ * @param clause an arbitrary string WHERE clause
75
+ * @param args any injected arguments into the clause string
76
+ */
77
+ where(clause: string, ...args: unknown[]): ModelQuery<PM,T,I>
78
+
79
+ where(mapOrClause: WhereMap<PM[T]> | string, ...args: unknown[]): ModelQuery<PM,T,I> {
80
+ if (typeof mapOrClause == 'object') {
81
+ this.whereMaps.push(mapOrClause)
82
+ } else {
83
+ this.whereClauses.push({clause: mapOrClause, args})
84
+ }
85
+ return this
86
+ }
87
+
88
+ private _includes: Includes<PM,T,I> = {}
89
+
90
+ /**
91
+ * Add one or more ActiveRecord-style includes.
92
+ * @param inc maps association names to potentially more association names, or empty objects.
93
+ */
94
+ includes(inc: Includes<PM,T,I>): ModelQuery<PM,T,I> {
95
+ this._includes = {...inc, ...this._includes}
96
+ return this
97
+ }
98
+
99
+ private _joins = Array<string>()
100
+
101
+ /**
102
+ * Adds an ActiveRecord-style joins statement.
103
+ * @param join the association to join
104
+ */
105
+ joins(join: string): ModelQuery<PM,T,I> {
106
+ this._joins.push(join)
107
+ return this
108
+ }
109
+
110
+ private order = ''
111
+
112
+ /**
113
+ * Set an ORDER BY statement for the query.
114
+ * @param order a SQL ORDER BY statement
115
+ */
116
+ orderBy(order: string): ModelQuery<PM,T,I> {
117
+ this.order = order
118
+ return this
119
+ }
120
+
121
+ private _limit = 0
122
+
123
+ /**
124
+ * Adds a result limit to the query.
125
+ * @param max the query limit
126
+ */
127
+ limit(max: number): ModelQuery<PM,T,I> {
128
+ this._limit = max
129
+ return this
130
+ }
131
+
132
+ /**
133
+ * Asynchronously execute the query on the server and returns the response.
134
+ * Only returns on success, throws otherwise.
135
+ */
136
+ async exec(): Promise<PM[T][]> {
137
+ const url = `/db/model/${this.modelType}.json`
138
+ const body = {
139
+ where_maps: this.whereMaps,
140
+ where_clauses: this.whereClauses,
141
+ includes: this._includes,
142
+ joins: this._joins,
143
+ limit: this._limit,
144
+ order: this.order
145
+ }
146
+ log.debug(`Getting ${this.modelType} query at ${url} with body`, body)
147
+ const res = await Api.safePost<DbGetResponse<PM[T]>>(url, body)
148
+ return res.records
149
+ }
150
+
151
+ /**
152
+ * Retrieves the first record.
153
+ */
154
+ async first(): Promise<PM[T] | undefined> {
155
+ const records = await this.limit(1).exec()
156
+ return records[0]
157
+ }
158
+
159
+ /**
160
+ * Counts the number of records represented by the query.
161
+ */
162
+ async count(): Promise<number> {
163
+ const url = `/db/model/${this.modelType}/count.json`
164
+ const body = {
165
+ where_maps: this.whereMaps,
166
+ where_clauses: this.whereClauses,
167
+ joins: this._joins
168
+ }
169
+ log.debug(`Counting ${this.modelType} query at ${url} with body`, body)
170
+ const res = await Api.safePost<DbCountResponse>(url, body)
171
+ return res.count
172
+ }
173
+
174
+ }
175
+
176
+ /**
177
+ * Generic database client that works with persisted and unpersisted type maps.
178
+ * @template PM the type map for persisted model types
179
+ * @template UM the type map for unpersisted model types
180
+ * @template I the type map for model includes
181
+ */
182
+ export default class DbClient<PM extends ModelTypeMap, UM extends ModelTypeMap, I extends ModelIncludesMap<PM>> {
183
+
184
+ /**
185
+ * Start a new query for the given model type.
186
+ * @param modelType the camel_case name of the model
187
+ */
188
+ query<T extends keyof PM & string>(modelType: T) {
189
+ return new ModelQuery<PM,T,I>(modelType)
190
+ }
191
+
192
+
193
+ /**
194
+ * Fetches a single record by id.
195
+ * Throws an error if the record doesn't exist.
196
+ * @param modelType the camel_case name of the model
197
+ * @param id the id of the record
198
+ * @param includes relations to include in the returned object
199
+ */
200
+ async find<T extends keyof PM & string>(modelType: T, id: string, includes?: I[T]): Promise<PM[T]> {
201
+ const query = new ModelQuery<PM,T,I>(modelType).where("id = ?", id)
202
+ if (includes) {
203
+ query.includes(includes)
204
+ }
205
+ const record = await query.first()
206
+ if (record) {
207
+ return record as PM[T]
208
+ } else {
209
+ throw new DbFindException(`No ${modelType} with id=${id}`)
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Fetches a single record by id *or* slug.
215
+ * Throws an error if the record doesn't exist.
216
+ * @todo See if we can narrow the definition of T to only include models with a slug column.
217
+ * @param modelType the camel_case name of the model
218
+ * @param idOrSlug the id or slug of the record
219
+ * @param includes relations to include in the returned object
220
+ */
221
+ async findByIdOrSlug<T extends keyof PM & string>(modelType: T, idOrSlug: string, includes?: I[T]): Promise<PM[T]> {
222
+ const column = isUuid(idOrSlug) ? "id" : "slug"
223
+ const query = new ModelQuery(modelType).where(`${column} = ?`, idOrSlug)
224
+ if (includes) {
225
+ query.includes(includes)
226
+ }
227
+ const record = await query.first()
228
+ if (record) {
229
+ return record as PM[T]
230
+ } else {
231
+ throw new DbFindException(`No ${modelType} with id or slug ${idOrSlug}`)
232
+ }
233
+ }
234
+
235
+
236
+ /**
237
+ * Updates the given record.
238
+ * @param modelType the camel_case name of the model
239
+ * @param record the record to update
240
+ * @param includes relations to include in the returned record
241
+ */
242
+ async update<T extends keyof PM & string>(modelType: T, record: PM[T], includes: Includes<PM,T,I> = {}): Promise<DbUpsertResponse<PM,T> & ApiResponse> {
243
+ const url = `/db/model/${modelType}/upsert.json`
244
+ const body = {record, includes}
245
+ log.debug(`Updating ${modelType} at ${url} with body`, body)
246
+ return await Api.post<DbUpsertResponse<PM,T>>(url, body)
247
+ }
248
+
249
+ /**
250
+ * Update the given record and assume it will succeed.
251
+ * This call should be wrapped in a try/catch.
252
+ * @param modelType the camel_case name of the model
253
+ * @param record the record to update
254
+ * @param includes relations to include in the returned record
255
+ */
256
+ async safeUpdate<T extends keyof PM & string>(modelType: T, record: PM[T], includes: Includes<PM,T,I> = {}): Promise<PM[T]> {
257
+ const res = await this.update(modelType, record, includes)
258
+ if (res.status == 'success') {
259
+ return res.record
260
+ } else if (res.record) {
261
+ throw new DbSaveException<PM, T>(res.message, res.record as UM[T], res.errors)
262
+ } else {
263
+ throw `Error updating ${modelType}: ${res.message}`
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Inserts a new record for the given model type.
269
+ * @param modelType the camel_case name of the model
270
+ * @param record the record to update
271
+ * @param includes relations to include in the returned record
272
+ */
273
+ async insert<T extends keyof PM & string>(modelType: T, record: UM[T], includes: Includes<PM,T,I> = {}): Promise<DbUpsertResponse<PM,T> & ApiResponse> {
274
+ const url = `/db/model/${modelType}/upsert.json`
275
+ const body = {record, includes}
276
+ log.debug(`Inserting ${modelType} at ${url} with body`, body)
277
+ return await Api.post<DbUpsertResponse<PM,T>>(url, body)
278
+ }
279
+
280
+ /**
281
+ * Inserts or updates a new record for the given model type,
282
+ * depending on if it has an id.
283
+ * @param modelType the camel_case name of the model
284
+ * @param record the record to update
285
+ * @param includes relations to include in the returned record
286
+ */
287
+ async upsert<T extends keyof PM & string>(modelType: T, record: UM[T], includes: Includes<PM,T,I> = {}) {
288
+ const url = `/db/model/${modelType}/upsert.json`
289
+ const body = {record, includes}
290
+ log.debug(`Upserting ${modelType} at ${url} with body`, body)
291
+ return await Api.post<DbUpsertResponse<PM,T>>(url, body)
292
+ }
293
+
294
+ /**
295
+ * Upserts the given record and assume it will succeed.
296
+ * This call should be wrapped in a try/catch.
297
+ * @param modelType the camel_case name of the model
298
+ * @param record the record to update
299
+ * @param includes relations to include in the returned record
300
+ */
301
+ async safeUpsert<T extends keyof PM & string>(modelType: T, record: UM[T], includes: Includes<PM,T,I> = {}) {
302
+ const res = await this.upsert(modelType, record, includes)
303
+ if (res.status == 'success') {
304
+ return res.record
305
+ } else if (res.record) {
306
+ throw new DbSaveException<PM,T>(res.message, res.record as UM[T], res.errors)
307
+ } else {
308
+ throw `Error upserting ${modelType}: ${res.message}`
309
+ }
310
+ }
311
+ }
312
+
313
+
314
+
315
+ /**
316
+ * The exception that gets raised when a find() call fails.
317
+ */
318
+ class DbFindException extends Error {
319
+ constructor(message: string) {
320
+ super(message)
321
+ }
322
+ }
323
+
324
+ export const UuidRegex = /[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/gi
325
+
326
+ const CompleteUuidRegex = new RegExp(`^${UuidRegex.source}$`)
327
+
328
+ function isUuid(str: string): boolean {
329
+ return !!str.match(CompleteUuidRegex)
330
+ }
331
+
332
+ /**
333
+ * Base validation errors that aren't associated with a particular column.
334
+ */
335
+ type DbBaseErrors = {
336
+ base?: string[]
337
+ }
338
+
339
+ /**
340
+ * Generic type of the errors object returned from ActiveRecord validations.
341
+ */
342
+ type DbModelErrors<T extends {}> = {
343
+ // either array of string errors or array of error objects on related records
344
+ [col in keyof T]?: string[]
345
+ }
346
+
347
+
348
+ export type DbErrors<T extends {}> = DbModelErrors<T> & DbBaseErrors
349
+
350
+ /**
351
+ * Generic type for a create or update response.
352
+ */
353
+ type DbUpsertResponse<PM extends ModelTypeMap, T extends keyof PM & string> = SuccessfulDbUpsertResponse<PM,T> | UnsuccessfulDbUpsertResponse<PM,T>
354
+
355
+ type SuccessfulDbUpsertResponse<PM extends ModelTypeMap, T extends keyof PM & string> = ApiResponse & {
356
+ status: 'success'
357
+ record: PM[T]
358
+ }
359
+
360
+ type UnsuccessfulDbUpsertResponse<PM extends ModelTypeMap, T extends keyof PM & string> = ApiResponse & {
361
+ status: 'error'
362
+ errors: DbErrors<PM[T]>
363
+ record?: PM[T]
364
+ }
365
+
366
+
367
+ /**
368
+ * The exception that gets raised when an insert or update call fails.
369
+ */
370
+ export class DbSaveException<UM extends ModelTypeMap, T extends keyof UM> extends Error {
371
+ record?: UM[T]
372
+ errors?: DbErrors<UM[T]>
373
+
374
+ constructor(message: string, record?: UM[T], errors?: DbErrors<UM[T]>) {
375
+ super(message)
376
+ this.record = record
377
+ this.errors = errors
378
+ }
379
+
380
+ log(message: string, logger: Logger | Console = window.console) {
381
+ logger.error(message, "| message:", `"${this.message}"`, "| errors:", this.errors, "| record:", this.record)
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Raised when a client-side validation fails (usually during form serialization)
387
+ */
388
+ export class ValidationException<PM extends ModelTypeMap, T extends keyof PM> extends DbSaveException<PM,T> {
389
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "terrier-engine",
3
+ "type": "module",
4
+ "files": [
5
+ "*"
6
+ ],
7
+ "version": "4.0.3",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/Terrier-Tech/terrier-engine"
11
+ },
12
+ "license": "MIT",
13
+ "scripts" : {
14
+ },
15
+ "dependencies": {
16
+ "tuff-core": "^0.24.1"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^18.16.3",
20
+ "prettier": "^2.8.8"
21
+ }
22
+ }