prostgles-server 4.2.613 → 4.2.615

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 (93) hide show
  1. package/dist/DBSchemaBuilder/DBSchemaBuilder.d.ts +2 -2
  2. package/dist/DBSchemaBuilder/DBSchemaBuilder.d.ts.map +1 -1
  3. package/dist/DboBuilder/DboBuilder.d.ts +2 -0
  4. package/dist/DboBuilder/DboBuilder.d.ts.map +1 -1
  5. package/dist/DboBuilder/DboBuilder.js +17 -3
  6. package/dist/DboBuilder/DboBuilder.js.map +1 -1
  7. package/dist/DboBuilder/TableHandler/TableHandler.d.ts +5 -5
  8. package/dist/DboBuilder/TableHandler/TableHandler.d.ts.map +1 -1
  9. package/dist/DboBuilder/TableHandler/TableHandler.js +17 -1
  10. package/dist/DboBuilder/TableHandler/TableHandler.js.map +1 -1
  11. package/dist/DboBuilder/TableHandler/delete.d.ts.map +1 -1
  12. package/dist/DboBuilder/TableHandler/delete.js +1 -0
  13. package/dist/DboBuilder/TableHandler/delete.js.map +1 -1
  14. package/dist/DboBuilder/TableHandler/executeAfterHooksCheckAndPostValidation.d.ts.map +1 -1
  15. package/dist/DboBuilder/TableHandler/executeAfterHooksCheckAndPostValidation.js +1 -1
  16. package/dist/DboBuilder/TableHandler/executeAfterHooksCheckAndPostValidation.js.map +1 -1
  17. package/dist/DboBuilder/TableHandler/insert/insert.d.ts.map +1 -1
  18. package/dist/DboBuilder/TableHandler/insert/insert.js +22 -2
  19. package/dist/DboBuilder/TableHandler/insert/insert.js.map +1 -1
  20. package/dist/DboBuilder/TableHandler/onDeleteFromFileTable.d.ts +3 -5
  21. package/dist/DboBuilder/TableHandler/onDeleteFromFileTable.d.ts.map +1 -1
  22. package/dist/DboBuilder/TableHandler/onDeleteFromFileTable.js +13 -26
  23. package/dist/DboBuilder/TableHandler/onDeleteFromFileTable.js.map +1 -1
  24. package/dist/DboBuilder/TableHandler/prepareBeforeHookData.d.ts +4 -0
  25. package/dist/DboBuilder/TableHandler/prepareBeforeHookData.d.ts.map +1 -0
  26. package/dist/DboBuilder/TableHandler/prepareBeforeHookData.js +20 -0
  27. package/dist/DboBuilder/TableHandler/prepareBeforeHookData.js.map +1 -0
  28. package/dist/DboBuilder/TableHandler/update.d.ts.map +1 -1
  29. package/dist/DboBuilder/TableHandler/update.js +36 -10
  30. package/dist/DboBuilder/TableHandler/update.js.map +1 -1
  31. package/dist/DboBuilder/TableHandler/updateFile.d.ts +5 -2
  32. package/dist/DboBuilder/TableHandler/updateFile.d.ts.map +1 -1
  33. package/dist/DboBuilder/TableHandler/updateFile.js +17 -11
  34. package/dist/DboBuilder/TableHandler/updateFile.js.map +1 -1
  35. package/dist/DboBuilder/TableHandler/uploadFile.d.ts +3 -2
  36. package/dist/DboBuilder/TableHandler/uploadFile.d.ts.map +1 -1
  37. package/dist/DboBuilder/TableHandler/uploadFile.js +15 -4
  38. package/dist/DboBuilder/TableHandler/uploadFile.js.map +1 -1
  39. package/dist/DboBuilder/runSql/getAdminClient.d.ts +3 -0
  40. package/dist/DboBuilder/runSql/getAdminClient.d.ts.map +1 -1
  41. package/dist/Logging.d.ts +1 -1
  42. package/dist/Logging.d.ts.map +1 -1
  43. package/dist/PublishParser/publishTypesAndUtils.d.ts +12 -5
  44. package/dist/PublishParser/publishTypesAndUtils.d.ts.map +1 -1
  45. package/dist/PublishParser/publishTypesAndUtils.js.map +1 -1
  46. package/dist/StorageClient/deleteUnreferencedFile.d.ts +4 -0
  47. package/dist/StorageClient/deleteUnreferencedFile.d.ts.map +1 -0
  48. package/dist/StorageClient/deleteUnreferencedFile.js +15 -0
  49. package/dist/StorageClient/deleteUnreferencedFile.js.map +1 -0
  50. package/dist/StorageClient/getFileStorageKey.d.ts +4 -0
  51. package/dist/StorageClient/getFileStorageKey.d.ts.map +1 -0
  52. package/dist/StorageClient/getFileStorageKey.js +7 -0
  53. package/dist/StorageClient/getFileStorageKey.js.map +1 -0
  54. package/dist/StorageClient/getFileTableConfig.d.ts +1 -0
  55. package/dist/StorageClient/getFileTableConfig.d.ts.map +1 -1
  56. package/dist/StorageClient/getFileTableConfig.js +9 -4
  57. package/dist/StorageClient/getFileTableConfig.js.map +1 -1
  58. package/dist/StorageClient/getFileType.d.ts.map +1 -1
  59. package/dist/StorageClient/getFileType.js +8 -10
  60. package/dist/StorageClient/getFileType.js.map +1 -1
  61. package/dist/StorageClient/getLocalStorageClient.d.ts.map +1 -1
  62. package/dist/StorageClient/getLocalStorageClient.js +20 -37
  63. package/dist/StorageClient/getLocalStorageClient.js.map +1 -1
  64. package/dist/StorageClient/getValidatedFileType.d.ts.map +1 -1
  65. package/dist/StorageClient/getValidatedFileType.js +4 -2
  66. package/dist/StorageClient/getValidatedFileType.js.map +1 -1
  67. package/dist/StorageClient/setupFileServeHandler.d.ts.map +1 -1
  68. package/dist/StorageClient/setupFileServeHandler.js +6 -3
  69. package/dist/StorageClient/setupFileServeHandler.js.map +1 -1
  70. package/dist/TableHooks/TableHooks.d.ts +10 -3
  71. package/dist/TableHooks/TableHooks.d.ts.map +1 -1
  72. package/lib/DBSchemaBuilder/DBSchemaBuilder.ts +3 -2
  73. package/lib/DboBuilder/DboBuilder.ts +18 -3
  74. package/lib/DboBuilder/TableHandler/TableHandler.ts +19 -1
  75. package/lib/DboBuilder/TableHandler/delete.ts +1 -0
  76. package/lib/DboBuilder/TableHandler/executeAfterHooksCheckAndPostValidation.ts +1 -2
  77. package/lib/DboBuilder/TableHandler/insert/insert.ts +31 -3
  78. package/lib/DboBuilder/TableHandler/onDeleteFromFileTable.ts +17 -33
  79. package/lib/DboBuilder/TableHandler/prepareBeforeHookData.ts +27 -0
  80. package/lib/DboBuilder/TableHandler/update.ts +40 -11
  81. package/lib/DboBuilder/TableHandler/updateFile.ts +25 -19
  82. package/lib/DboBuilder/TableHandler/uploadFile.ts +32 -7
  83. package/lib/Logging.ts +1 -0
  84. package/lib/PublishParser/publishTypesAndUtils.ts +26 -15
  85. package/lib/StorageClient/deleteUnreferencedFile.ts +19 -0
  86. package/lib/StorageClient/getFileStorageKey.ts +5 -0
  87. package/lib/StorageClient/getFileTableConfig.ts +19 -5
  88. package/lib/StorageClient/getFileType.ts +11 -14
  89. package/lib/StorageClient/getLocalStorageClient.ts +20 -43
  90. package/lib/StorageClient/getValidatedFileType.ts +4 -1
  91. package/lib/StorageClient/setupFileServeHandler.ts +6 -3
  92. package/lib/TableHooks/TableHooks.ts +23 -13
  93. package/package.json +8 -8
@@ -1,9 +1,9 @@
1
1
  import type pgPromise from "pg-promise";
2
2
  import type { AnyObject } from "prostgles-types";
3
- import { asName, pickKeys } from "prostgles-types";
3
+ import { asName } from "prostgles-types";
4
4
  import type { FileTableConfig } from "../../ProstglesTypes";
5
- import type { TableHandlers } from "../DboBuilder";
6
- import type { FileTableRow } from "../../StorageClient/getFileTableConfig";
5
+ import type { OnCommit } from "../../PublishParser/publishTypesAndUtils";
6
+ import { deleteUnreferencedFile } from "../../StorageClient/deleteUnreferencedFile";
7
7
 
8
8
  type OnDeleteFromFileTableArgs = {
9
9
  returningQuery: undefined | string;
@@ -12,18 +12,14 @@ type OnDeleteFromFileTableArgs = {
12
12
  filter: AnyObject;
13
13
  };
14
14
  t: pgPromise.ITask<{}>;
15
- dbTX: TableHandlers;
15
+ onCommit: OnCommit;
16
16
  isOneOrNone: boolean;
17
17
  };
18
18
  export const onDeleteFromFileTable = async (
19
19
  config: FileTableConfig,
20
- { dbTX, t, returningQuery, filterOpts, isOneOrNone }: OnDeleteFromFileTableArgs,
20
+ { onCommit, t, returningQuery, filterOpts, isOneOrNone }: OnDeleteFromFileTableArgs,
21
21
  ) => {
22
- const { storageClient, tableName } = config;
23
- const tableHandler = dbTX[tableName];
24
- if (!tableHandler) {
25
- throw new Error(`TableHandler for ${tableName} not found in dbTX`);
26
- }
22
+ const { tableName } = config;
27
23
  if (config.delayedDelete) {
28
24
  const result = await t.any(
29
25
  `UPDATE ${asName(tableName)} SET deleted = now() ${filterOpts.where} ${returningQuery ?? ""};`,
@@ -31,27 +27,15 @@ export const onDeleteFromFileTable = async (
31
27
  return (isOneOrNone ? result[0] : result) as undefined | AnyObject[];
32
28
  }
33
29
 
34
- let files: { id: string }[] = [];
35
- const totalFiles = await tableHandler.count(filterOpts.filter);
36
- do {
37
- const batch = (await tableHandler.find(filterOpts.filter, {
38
- limit: 100,
39
- offset: files.length,
40
- })) as FileTableRow[];
41
- files = [...files, ...batch];
42
- } while (files.length < totalFiles);
43
-
44
- for (const file of files) {
45
- await t.any(`DELETE FROM ${asName(tableName)} WHERE id = \${id}`, file);
46
- }
47
- /** If any table delete fails then do not delete files */
48
- for (const file of files) {
49
- await storageClient.delete(file.id);
50
- }
51
-
52
- if (returningQuery) {
53
- return files.map((f) => pickKeys(f, ["id"]));
54
- }
55
-
56
- return undefined;
30
+ const files = await t.any<Record<string, unknown> & { _prostgles_storage_key: string }>(
31
+ `DELETE FROM ${asName(tableName)} ${filterOpts.where}
32
+ ${returningQuery ? `${returningQuery},` : "RETURNING"}
33
+ COALESCE(storage_key, id) AS _prostgles_storage_key`,
34
+ );
35
+ const result = files.map(({ _prostgles_storage_key, ...row }) => {
36
+ onCommit(({ db }) => deleteUnreferencedFile(db, config, _prostgles_storage_key));
37
+ return row;
38
+ });
39
+ if (!returningQuery) return undefined;
40
+ return (isOneOrNone ? result[0] : result) as AnyObject[] | undefined;
57
41
  };
@@ -0,0 +1,27 @@
1
+ import { isObject, pickKeys, type AnyObject, type FieldFilter } from "prostgles-types";
2
+ import { validateKeys } from "../ViewHandler/ViewHandler";
3
+ import type { TableHandler } from "./TableHandler";
4
+
5
+ export const prepareBeforeHookData = (
6
+ table: TableHandler,
7
+ row: AnyObject,
8
+ fields: FieldFilter | undefined,
9
+ removeDisallowedFields: boolean,
10
+ command: "insert" | "update",
11
+ ) => {
12
+ const allowed = table.parseFieldFilter(fields, false);
13
+ // Nested table inserts are checked against their own publish rules later.
14
+ const nestedTables =
15
+ command === "insert" && !table.is_media ?
16
+ Object.keys(row).filter(
17
+ (key) =>
18
+ !table.columnSet.has(key) &&
19
+ table.dboBuilder.dboMap.has(key) &&
20
+ (isObject(row[key]) || Array.isArray(row[key]) || row[key] === undefined),
21
+ )
22
+ : [];
23
+ const allowedKeys = [...allowed, ...nestedTables];
24
+ const data = removeDisallowedFields ? pickKeys(row, allowedKeys) : { ...row };
25
+ validateKeys(data, allowedKeys);
26
+ return data;
27
+ };
@@ -7,7 +7,9 @@ import { prepareNewData } from "./DataValidator";
7
7
  import { getInsertTableRules } from "./insert/getInsertTableRules";
8
8
  import { getReferenceColumnInserts } from "./insert/getReferenceColumnInserts";
9
9
  import { runInsertUpdateQuery } from "./runInsertUpdateQuery";
10
+ import { getFileUpdateId } from "./updateFile";
10
11
  import type { TableHandler } from "./TableHandler";
12
+ import { prepareBeforeHookData } from "./prepareBeforeHookData";
11
13
 
12
14
  export async function update(
13
15
  this: TableHandler,
@@ -65,7 +67,37 @@ export async function update(
65
67
  );
66
68
  }
67
69
 
68
- const beforeResult = await this.beforeEach(newData, localParams, "update", filter);
70
+ const updateFilter = await this.prepareWhere({
71
+ select: undefined,
72
+ filter,
73
+ forcedFilter,
74
+ filterFields,
75
+ localParams,
76
+ tableRule: tableRules,
77
+ });
78
+ if (this.is_media) getFileUpdateId(filter);
79
+ if (this.getBeforeHooks("update", [newData]).length) {
80
+ newData = prepareBeforeHookData(this, newData, fields, removeDisallowedFields, "update");
81
+ if (params?.returnType?.startsWith("statement") || localParams?.returnQuery) {
82
+ throw new Error("Updates with beforeEach hooks cannot return SQL statements");
83
+ }
84
+ if (!transaction) throw new Error("beforeEach hooks require a transaction");
85
+ const permittedRows = await transaction.t.any(
86
+ withUserRLS(
87
+ localParams,
88
+ `SELECT 1 FROM ${this.escapedName} ${updateFilter.where} FOR UPDATE`,
89
+ true,
90
+ ),
91
+ );
92
+ if (!permittedRows.length) {
93
+ return params?.returning && params.multi !== false ? [] : undefined;
94
+ }
95
+ if (params?.multi === false && permittedRows.length > 1) {
96
+ throw `More than 1 row modified: ${permittedRows.length} rows affected`;
97
+ }
98
+ }
99
+
100
+ const beforeResult = await this.beforeEach(newData, localParams, "update", updateFilter.filter);
69
101
  newData = beforeResult.row;
70
102
 
71
103
  const { data, allowedCols } = prepareNewData({
@@ -79,15 +111,6 @@ export async function update(
79
111
  columnsAddedFromBeforeHooks: beforeResult.columnsAdded,
80
112
  });
81
113
 
82
- const updateFilter = await this.prepareWhere({
83
- select: undefined,
84
- filter,
85
- forcedFilter,
86
- filterFields,
87
- localParams,
88
- tableRule: tableRules,
89
- });
90
-
91
114
  /**
92
115
  * Nested inserts
93
116
  */
@@ -105,10 +128,13 @@ export async function update(
105
128
  if (+updateCount > 1) {
106
129
  throw "Cannot do a nestedInsert from an update that targets more than 1 row";
107
130
  }
131
+ if (!+updateCount) {
132
+ return params?.returning && params.multi !== false ? [] : undefined;
133
+ }
108
134
  if (!transaction) {
109
135
  return wrapInTx();
110
136
  }
111
- await Promise.all(
137
+ const nestedResults = await Promise.allSettled(
112
138
  nestedInserts.map(async (nestedInsert) => {
113
139
  const nestedTableHandler = transaction.dbTX[nestedInsert.tableName];
114
140
  if (!nestedTableHandler)
@@ -151,6 +177,9 @@ export async function update(
151
177
  };
152
178
  }),
153
179
  );
180
+ for (const result of nestedResults) {
181
+ if (result.status === "rejected") throw result.reason;
182
+ }
154
183
  }
155
184
 
156
185
  const tx = localParams?.tx?.t || this.tx?.t || this.db;
@@ -4,18 +4,40 @@ import type { FileTableConfig } from "../../ProstglesTypes";
4
4
  import type { FileTableRow } from "../../StorageClient/getFileTableConfig";
5
5
  import type { TableHandler } from "./TableHandler";
6
6
  import { uploadFile, type UploadFileArgs } from "./uploadFile";
7
+ import { deleteUnreferencedFile } from "../../StorageClient/deleteUnreferencedFile";
8
+ import { getFileStorageKey } from "../../StorageClient/getFileStorageKey";
7
9
 
8
10
  type Args = Pick<
9
11
  UploadFileArgs,
10
- "localParams" | "data" | "original_name" | "original_last_modified"
12
+ "localParams" | "data" | "original_name" | "original_last_modified" | "onCommit" | "onRollback"
11
13
  > & {
12
14
  filter: AnyObject;
13
15
  };
14
16
  export const updateFile = async (
15
17
  tableHandler: TableHandler,
16
18
  config: FileTableConfig,
17
- { filter, data, original_name, original_last_modified, localParams }: Args,
19
+ { filter, data, original_name, original_last_modified, localParams, onCommit, onRollback }: Args,
18
20
  ) => {
21
+ const existingFile = (await tableHandler.findOne(filter)) as FileTableRow | undefined;
22
+
23
+ if (!existingFile?.id) {
24
+ throw new Error("Existing file record not found");
25
+ }
26
+
27
+ onCommit(({ db }) => deleteUnreferencedFile(db, config, getFileStorageKey(existingFile)));
28
+ const newFile = await uploadFile(config, {
29
+ onCommit,
30
+ onRollback,
31
+ original_name,
32
+ data,
33
+ localParams,
34
+ mediaId: existingFile.id,
35
+ original_last_modified,
36
+ });
37
+ return { newData: omitKeys(newFile, ["id"]) };
38
+ };
39
+
40
+ export const getFileUpdateId = (filter: AnyObject): string => {
19
41
  const { data: validFilter } = getJSONBObjectSchemaValidationError(
20
42
  { id: { optional: true, type: "string" } },
21
43
  filter,
@@ -30,21 +52,5 @@ export const updateFile = async (
30
52
  );
31
53
  }
32
54
 
33
- const existingFile = (await tableHandler.findOne({
34
- id: existingMediaId,
35
- })) as FileTableRow | undefined;
36
-
37
- if (!existingFile?.id) {
38
- throw new Error("Existing file record not found");
39
- }
40
-
41
- await config.storageClient.delete(existingFile.id);
42
- const newFile = await uploadFile(config, {
43
- original_name,
44
- data,
45
- localParams,
46
- mediaId: existingFile.id,
47
- original_last_modified,
48
- });
49
- return { newData: omitKeys(newFile, ["id"]) };
55
+ return existingMediaId;
50
56
  };
@@ -4,7 +4,12 @@ import type { FileTableConfig } from "../../ProstglesTypes";
4
4
  import type { FileTableRow } from "../../StorageClient/getFileTableConfig";
5
5
  import { getValidatedFileType } from "../../StorageClient/getValidatedFileType";
6
6
  import { getFileServeRoute } from "../../StorageClient/setupFileServeHandler";
7
+ import { deleteUnreferencedFile } from "../../StorageClient/deleteUnreferencedFile";
7
8
  import type { LocalParams } from "../DboBuilder";
9
+ import type {
10
+ OnCommitCallback,
11
+ TransactionCallbacks,
12
+ } from "../../PublishParser/publishTypesAndUtils";
8
13
 
9
14
  const FILE_SCHEMA = {
10
15
  id: { type: "string", optional: true },
@@ -30,7 +35,7 @@ export const assertFileObjectValid: AssertFileObjectValid = (row) => {
30
35
  }
31
36
  };
32
37
 
33
- export type UploadFileArgs = {
38
+ export type UploadFileArgs = TransactionCallbacks & {
34
39
  data: Buffer<ArrayBufferLike>;
35
40
  original_name: string;
36
41
  original_last_modified: string | null;
@@ -43,7 +48,15 @@ export type UploadFileArgs = {
43
48
 
44
49
  export const uploadFile = async (
45
50
  config: FileTableConfig,
46
- { data, original_name, original_last_modified, localParams, mediaId }: UploadFileArgs,
51
+ {
52
+ data,
53
+ original_name,
54
+ original_last_modified,
55
+ localParams,
56
+ mediaId,
57
+ onCommit,
58
+ onRollback,
59
+ }: UploadFileArgs,
47
60
  ): Promise<Omit<FileTableRow, "added"> & Partial<Pick<FileTableRow, "added">>> => {
48
61
  const storageClient = config.storageClient;
49
62
 
@@ -64,11 +77,22 @@ export const uploadFile = async (
64
77
  content_type: type.mime,
65
78
  };
66
79
 
67
- const uploadedInfo = await storageClient.upload({
68
- file: data,
69
- fileName: coreInfo.id,
70
- contentType: coreInfo.content_type,
71
- });
80
+ const storageKey = randomUUID();
81
+ const pendingUpload = Promise.resolve().then(async () =>
82
+ storageClient.upload({
83
+ file: data,
84
+ fileName: storageKey,
85
+ contentType: coreInfo.content_type,
86
+ }),
87
+ );
88
+ const cleanup: OnCommitCallback = async ({ db }) => {
89
+ // A sibling operation may have failed while this upload was still running.
90
+ await pendingUpload.catch(() => {});
91
+ await deleteUnreferencedFile(db, config, storageKey);
92
+ };
93
+ onCommit(cleanup);
94
+ onRollback(cleanup);
95
+ const uploadedInfo = await pendingUpload;
72
96
 
73
97
  const { contentLength, contentHash } = uploadedInfo;
74
98
  const { filePath } = uploadedInfo.type === "local" ? uploadedInfo : {};
@@ -76,6 +100,7 @@ export const uploadFile = async (
76
100
 
77
101
  const fileServeRoute = getFileServeRoute(config);
78
102
  const mediaRow: Omit<FileTableRow, "added"> = {
103
+ storage_key: storageKey,
79
104
  signed_url: null,
80
105
  signed_url_expires: null,
81
106
  updated: new Date().toISOString(),
package/lib/Logging.ts CHANGED
@@ -176,6 +176,7 @@ export namespace EventTypes {
176
176
  | "initFileManager.runQuery"
177
177
  | "DboBuilder.getTablesForSchemaPostgresSQL"
178
178
  | "DboBuilder.onCommit"
179
+ | "DboBuilder.onRollback"
179
180
  | "PubSubManager.create";
180
181
  data?: AnyObject;
181
182
  }
@@ -88,11 +88,12 @@ export type BeforeEachTsTrigger<R, DBX, Context = undefined> = {
88
88
  */
89
89
  changedFields?: (keyof R)[];
90
90
  validate: (
91
- params: ValidateBeforeRowArgsCommon<R, DBX> & {
92
- localParams: undefined | LocalParams;
93
- hookContext: AnyObject | undefined;
94
- context: Context;
95
- },
91
+ params: ValidateBeforeRowArgsCommon<R, DBX> &
92
+ TransactionCallbacks<DBX> & {
93
+ localParams: undefined | LocalParams;
94
+ hookContext: AnyObject | undefined;
95
+ context: Context;
96
+ },
96
97
  ) => MaybePromise<void | { row: Partial<R>; hookContext?: AnyObject; onInserted?: () => void }>;
97
98
  };
98
99
 
@@ -102,6 +103,16 @@ export type OnCommitCallback<DBO = DBHandlerServer> = (args: {
102
103
  }) => MaybePromise<unknown>;
103
104
  export type OnCommit<DBO = DBHandlerServer> = (callback: OnCommitCallback<DBO>) => void;
104
105
 
106
+ export type TransactionCallbacks<DBO = DBHandlerServer> = {
107
+ /** Awaited after the outer transaction commits. Errors are logged. */
108
+ onCommit: OnCommit<DBO>;
109
+ /**
110
+ * Awaited after the outer transaction rejects. Errors are logged.
111
+ * A failed COMMIT acknowledgement may have committed; verify state before destructive cleanup.
112
+ */
113
+ onRollback: OnCommit<DBO>;
114
+ };
115
+
105
116
  export type AfterEachTsTrigger<R, DBX, Context = undefined> = {
106
117
  commands: Partial<Record<"insert" | "update" | "delete", 1>>;
107
118
  /**
@@ -109,11 +120,11 @@ export type AfterEachTsTrigger<R, DBX, Context = undefined> = {
109
120
  */
110
121
  changedFields?: (keyof R)[];
111
122
  validate: (
112
- params: ValidateRowArgsCommon<R, DBX> & {
113
- localParams: undefined | LocalParams;
114
- context: Context;
115
- onCommit: OnCommit<DBX>;
116
- },
123
+ params: ValidateRowArgsCommon<R, DBX> &
124
+ TransactionCallbacks<DBX> & {
125
+ localParams: undefined | LocalParams;
126
+ context: Context;
127
+ },
117
128
  ) => Promise<void>;
118
129
  };
119
130
 
@@ -152,11 +163,11 @@ export type AfterAllTsTrigger<R, DBX, Context = undefined> = {
152
163
  commands: Partial<Record<"insert" | "update" | "delete", 1>>;
153
164
  changedFields?: string[];
154
165
  validate: (
155
- params: ValidateRowsArgsCommon<R, DBX> & {
156
- localParams: undefined | LocalParams;
157
- context: Context;
158
- onCommit: OnCommit<DBX>;
159
- },
166
+ params: ValidateRowsArgsCommon<R, DBX> &
167
+ TransactionCallbacks<DBX> & {
168
+ localParams: undefined | LocalParams;
169
+ context: Context;
170
+ },
160
171
  ) => Promise<void>;
161
172
  };
162
173
 
@@ -0,0 +1,19 @@
1
+ import type { DB } from "../Prostgles";
2
+ import type { FileTableConfig } from "../ProstglesTypes";
3
+
4
+ export const deleteUnreferencedFile = async (
5
+ db: DB,
6
+ config: FileTableConfig,
7
+ storageKey: string,
8
+ ) => {
9
+ // A failed COMMIT response does not prove rollback. Check committed state first.
10
+ // Fail closed if RLS would hide a reference or the database is unavailable.
11
+ const referenced = await db.tx(async (tx) => {
12
+ await tx.none("SET LOCAL row_security = off");
13
+ return tx.oneOrNone<{ found: number }>(
14
+ "SELECT 1 FROM ${table:name} WHERE storage_key = ${key}::uuid OR (storage_key IS NULL AND id = ${key}::uuid) LIMIT 1",
15
+ { table: config.tableName, key: storageKey },
16
+ );
17
+ });
18
+ if (!referenced) await config.storageClient.delete(storageKey);
19
+ };
@@ -0,0 +1,5 @@
1
+ import type { FileTableRow } from "./getFileTableConfig";
2
+
3
+ /** Legacy files are stored under their logical ID. */
4
+ export const getFileStorageKey = (file: Pick<FileTableRow, "id" | "storage_key">) =>
5
+ file.storage_key ?? file.id;
@@ -8,9 +8,11 @@ import type { TableConfig } from "../TableConfig/TableConfigTypes";
8
8
  import type { TableRowFromColumnDefinitions } from "../TableConfig/TableRowFromColumnDefinitions";
9
9
  import type { TableHooks } from "../TableHooks/TableHooks";
10
10
  import { setupFileServeHandler } from "./setupFileServeHandler";
11
+ import type { TableHandlers } from "../DboBuilder/DboBuilder";
11
12
 
12
13
  const FILE_TABLE_COLUMN_DEFINITIONS = {
13
14
  id: `UUID PRIMARY KEY DEFAULT gen_random_uuid()`,
15
+ storage_key: `UUID UNIQUE`, // NULL for legacy objects stored under their file ID.
14
16
  extension: `TEXT NOT NULL DEFAULT ''`,
15
17
  content_type: `TEXT NOT NULL DEFAULT ''`,
16
18
  content_length: `BIGINT NOT NULL DEFAULT 0`,
@@ -101,8 +103,16 @@ export const getFileTableConfig = (
101
103
  insert: 1,
102
104
  update: 1,
103
105
  },
104
- validate: async ({ data: insertData, localParams, command, filter }) => {
105
- const tableHandler = prg.dboBuilder.dboMap.get(fileTableName);
106
+ validate: async ({
107
+ data: insertData,
108
+ localParams,
109
+ command,
110
+ filter,
111
+ dbx,
112
+ onCommit,
113
+ onRollback,
114
+ }) => {
115
+ const tableHandler = dbx[fileTableName];
106
116
  if (!tableHandler) throw "Storage tableHandler not found";
107
117
  assertFileObjectValid(insertData);
108
118
 
@@ -110,6 +120,8 @@ export const getFileTableConfig = (
110
120
  const data = dataBlob as unknown as Buffer;
111
121
  if (command === "update") {
112
122
  const { newData } = await updateFile(tableHandler, fileTable, {
123
+ onCommit,
124
+ onRollback,
113
125
  filter: filter ?? {},
114
126
  localParams,
115
127
  data,
@@ -125,6 +137,8 @@ export const getFileTableConfig = (
125
137
  }
126
138
 
127
139
  const media = await uploadFile(fileTable, {
140
+ onCommit,
141
+ onRollback,
128
142
  data,
129
143
  original_name,
130
144
  localParams,
@@ -139,12 +153,12 @@ export const getFileTableConfig = (
139
153
  },
140
154
  };
141
155
  },
142
- } satisfies BeforeEachTsTrigger<FileTableRow, {}>,
156
+ } satisfies BeforeEachTsTrigger<FileTableRow, TableHandlers>,
143
157
  ...(userFileTableHooks?.beforeEach || []),
144
158
  ],
145
- onInsteadOfDelete: async ({ dbx, tx, returningQuery, isOneOrNone, filterOpts }) => {
159
+ onInsteadOfDelete: async ({ onCommit, tx, returningQuery, isOneOrNone, filterOpts }) => {
146
160
  return onDeleteFromFileTable(fileTable, {
147
- dbTX: dbx,
161
+ onCommit,
148
162
  t: tx,
149
163
  returningQuery,
150
164
  isOneOrNone,
@@ -1,8 +1,3 @@
1
- // const fileType = require("file-type");
2
- // const res = await fileType.fromBuffer(typeof file === "string"? Buffer.from(file, 'utf8') : file);
3
-
4
- // import * as sharp from "sharp";
5
-
6
1
  import type { ALLOWED_CONTENT_TYPE, ALLOWED_EXTENSION } from "prostgles-types";
7
2
  import { CONTENT_TYPE_TO_EXT, getKeys } from "prostgles-types";
8
3
 
@@ -17,24 +12,26 @@ export const getFileType = async (
17
12
 
18
13
  const fileNameMime = getFileTypeFromFilename(fileName);
19
14
  if (!fileNameMime?.ext) throw new Error("File name must contain extension");
20
- const res = await fileTypeFromBuffer(typeof file === "string" ? Buffer.from(file, "utf8") : file);
21
-
22
- if (!res) {
23
- /* Set correct/missing extension */
24
- const nameExt = fileNameMime.ext;
25
- if (["xml", "txt", "csv", "tsv", "svg", "sql"].includes(nameExt)) {
15
+ const detectedFileType = await fileTypeFromBuffer(
16
+ typeof file === "string" ? Buffer.from(file, "utf8") : file,
17
+ );
18
+
19
+ if (!detectedFileType) {
20
+ // Text formats may not have a detectable binary signature.
21
+ const { mime } = fileNameMime;
22
+ if (mime.startsWith("text/") || mime.endsWith("+xml") || mime === "application/sql") {
26
23
  return fileNameMime;
27
24
  }
28
25
 
29
26
  throw new Error("Could not get the file type from file buffer");
30
27
  } else {
31
- if (fileNameMime.ext.toLowerCase() !== res.ext.toLowerCase()) {
28
+ if (fileNameMime.ext.toLowerCase() !== detectedFileType.ext.toLowerCase()) {
32
29
  throw new Error(
33
- `There is a mismatch between file name extension and actual buffer extension: ${fileNameMime.ext} vs ${res.ext}`,
30
+ `There is a mismatch between file name extension and actual buffer extension: ${fileNameMime.ext} vs ${detectedFileType.ext}`,
34
31
  );
35
32
  }
36
33
  }
37
- return res as any;
34
+ return detectedFileType as any;
38
35
  };
39
36
 
40
37
  export const getFileTypeFromFilename = (
@@ -2,6 +2,7 @@ import crypto from "crypto";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  import stream from "stream";
5
+ import { pipeline } from "stream/promises";
5
6
  import type {
6
7
  UploadFileOptions,
7
8
  LocalStorageClient,
@@ -55,52 +56,28 @@ export const getLocalStorageClient = (localConfig: LocalConfig): LocalStorageCli
55
56
  const hash = crypto.createHash("md5");
56
57
  let contentLength = 0;
57
58
 
58
- return new Promise((resolve, reject) => {
59
- const writeStream = fs.createWriteStream(filePath);
60
-
61
- writeStream.on("error", reject);
62
- writeStream.on("finish", () => {
63
- resolve({
64
- type: "local",
65
- filePath,
66
- contentHash: hash.digest("hex"),
67
- contentLength: contentLength,
68
- });
69
- });
70
-
71
- if (typeof args.file === "string" || Buffer.isBuffer(args.file)) {
72
- const buffer = Buffer.isBuffer(args.file) ? args.file : Buffer.from(args.file);
73
-
74
- contentLength = buffer.length;
75
- hash.update(buffer);
76
-
77
- if (args.onProgress) {
59
+ const buffered = typeof args.file === "string" || Buffer.isBuffer(args.file);
60
+ const source = buffered ? stream.Readable.from([args.file]) : args.file;
61
+ let lastProgress = Date.now();
62
+ const measure = new stream.Transform({
63
+ transform: (chunk: Buffer, _encoding, callback) => {
64
+ contentLength += chunk.length;
65
+ hash.update(chunk);
66
+ if (args.onProgress && (buffered || Date.now() - lastProgress > 1000)) {
67
+ lastProgress = Date.now();
78
68
  args.onProgress(contentLength);
79
69
  }
80
-
81
- writeStream.write(buffer);
82
- writeStream.end();
83
- } else if (
84
- args.file instanceof stream.Readable ||
85
- (args.file as unknown) instanceof stream.PassThrough
86
- ) {
87
- let lastProgress = Date.now();
88
- const throttle = 1000; // 1 second
89
- args.file.on("data", (chunk: Buffer) => {
90
- contentLength += chunk.length;
91
- hash.update(chunk);
92
- if (args.onProgress && Date.now() - lastProgress > throttle) {
93
- lastProgress = Date.now();
94
- args.onProgress(contentLength);
95
- }
96
- });
97
-
98
- args.file.on("error", reject);
99
- args.file.pipe(writeStream);
100
- } else {
101
- reject(new Error("Unsupported file type provided to upload."));
102
- }
70
+ callback(null, chunk);
71
+ },
103
72
  });
73
+ // Wait for all streams to close before transaction cleanup can remove a failed upload.
74
+ await pipeline(source, measure, fs.createWriteStream(filePath));
75
+ return {
76
+ type: "local",
77
+ filePath,
78
+ contentHash: hash.digest("hex"),
79
+ contentLength,
80
+ };
104
81
  },
105
82
 
106
83
  downloadAsStream: async (name: string) => {
@@ -87,6 +87,9 @@ export const getValidatedFileType = async (
87
87
  }
88
88
  }
89
89
  }
90
- if (!result?.mime) throw `File MIME type not found for the provided extension: ${result?.ext}`;
90
+ if (!result?.mime) {
91
+ const extension = fileName.includes(".") ? fileName.split(".").at(-1) : "(none)";
92
+ throw `File MIME type not found for the provided extension: ${extension}`;
93
+ }
91
94
  return result;
92
95
  };