@memberjunction/server 6.1.3 → 6.1.5
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/README.md +30 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +49 -35
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.js +20 -52
- package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
- package/dist/config.d.ts +53 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +11 -23
- package/dist/context.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +87 -3
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +118 -3
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/DeleteOptionsInput.d.ts +8 -1
- package/dist/generic/DeleteOptionsInput.d.ts.map +1 -1
- package/dist/generic/DeleteOptionsInput.js +1 -1
- package/dist/generic/DeleteOptionsInput.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +4 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +10 -3
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -47
- package/dist/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts +10 -20
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +54 -174
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +2 -1
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/services/TaskGraphActionRunner.d.ts +13 -8
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +16 -15
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +12 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
- package/src/__tests__/DeleteOptionsInput.wireShape.test.ts +62 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +129 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +177 -0
- package/src/__tests__/magicLink.test.ts +15 -76
- package/src/__tests__/magicLinkService.consume.test.ts +224 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/auth/magicLink/MagicLinkService.ts +55 -36
- package/src/auth/magicLink/magicLinkCore.ts +19 -56
- package/src/config.ts +23 -0
- package/src/context.ts +11 -24
- package/src/generic/CacheInvalidationResolver.ts +139 -3
- package/src/generic/DeleteOptionsInput.ts +9 -2
- package/src/generic/ResolverBase.ts +11 -3
- package/src/index.ts +55 -48
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/resolvers/AdhocQueryResolver.ts +64 -194
- package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
- package/src/resolvers/RunAIAgentResolver.ts +4 -1
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/services/TaskGraphActionRunner.ts +16 -15
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -42
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- package/src/resolvers/adhoc-query-helpers.ts +0 -50
|
@@ -106,75 +106,38 @@ export function evaluateInvite(invite: InviteEvaluationInput, nowMs: number): {
|
|
|
106
106
|
return { ok: true };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/** SQL dialect for {@link buildConsumeInviteSQL}. */
|
|
110
|
-
export type ConsumeInviteDialect = 'sqlserver' | 'postgresql';
|
|
111
|
-
|
|
112
|
-
/** SQL Server table identifier — bracket-quoted `[schema].[table]` (word chars only). */
|
|
113
|
-
const QUALIFIED_TABLE_PATTERN = /^\[\w+\]\.\[\w+\]$/;
|
|
114
109
|
/** PostgreSQL table identifier — `schema.table` (word chars only); auto-quoted downstream. */
|
|
115
110
|
const QUALIFIED_TABLE_PATTERN_PG = /^\w+\.\w+$/;
|
|
116
111
|
|
|
117
112
|
/**
|
|
118
|
-
* Builds the atomic compare-and-swap UPDATE that consumes one use of an invite
|
|
119
|
-
* in the given SQL dialect.
|
|
120
|
-
*
|
|
121
|
-
* The WHERE clause re-checks every eligibility condition at the DB level, so the
|
|
122
|
-
* increment and the guard are a single atomic operation: concurrent redemptions
|
|
123
|
-
* of a single-use link race on the row and exactly one matches. This is what
|
|
124
|
-
* actually enforces single-use — the JS-side `evaluateInvite` is only a friendly
|
|
125
|
-
* pre-check. The matched row's ID is returned to the caller (via `OUTPUT` on SQL
|
|
126
|
-
* Server, `RETURNING` on PostgreSQL) so it can detect a win (exactly one row).
|
|
127
|
-
*
|
|
128
|
-
* The invite ID MUST be bound as a parameter by the caller (`@p0` on SQL Server,
|
|
129
|
-
* `$1` on PostgreSQL) — it is never interpolated into the string, so this builder
|
|
130
|
-
* is injection-safe regardless of the ID's contents.
|
|
113
|
+
* Builds the PostgreSQL atomic compare-and-swap UPDATE that consumes one use of an invite.
|
|
131
114
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
115
|
+
* PostgreSQL only. SQL Server consumes through the granted `spConsumeMagicLinkInvite` procedure,
|
|
116
|
+
* because its runtime roles are never granted table DML (#4753). PostgreSQL needs neither: its
|
|
117
|
+
* runtime roles hold INSERT/UPDATE/DELETE on every `__mj` table
|
|
118
|
+
* (V202605040300__v5.33.x__Unblock_PostgreSQL_End_To_End.pg-only.sql), and no PG counterpart of
|
|
119
|
+
* the procedure exists — the release-time converter leaves `CREATE PROCEDURE` unhandled.
|
|
136
120
|
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* serialize and
|
|
140
|
-
*
|
|
141
|
-
* `OUTPUT`-into-table win-detect). PascalCase identifiers are auto-quoted by
|
|
142
|
-
* `PostgreSQLDataProvider.ExecuteSQL`, so the table is passed unquoted (`schema.table`).
|
|
121
|
+
* The WHERE re-checks every eligibility condition (the same predicate as the procedure), so the
|
|
122
|
+
* increment and the guard are one atomic statement: concurrent redemptions of a single-use link
|
|
123
|
+
* serialize on the row lock and exactly one matches. `RETURNING ID` yields the row iff the guard
|
|
124
|
+
* matched. The invite ID MUST be bound by the caller as `$1` — it is never interpolated.
|
|
143
125
|
*
|
|
144
|
-
* `qualifiedTable` is
|
|
145
|
-
*
|
|
146
|
-
* this is defense-in-depth
|
|
147
|
-
* an injection vector — a non-conforming table throws.
|
|
148
|
-
*
|
|
149
|
-
* @param qualifiedTable `[schema].[table]` (sqlserver) or `schema.table` (postgresql) for MagicLinkInvite
|
|
150
|
-
* @param dialect target SQL dialect (defaults to `'sqlserver'`)
|
|
126
|
+
* `qualifiedTable` is `schema.table` unquoted (PostgreSQLDataProvider.ExecuteSQL auto-quotes the
|
|
127
|
+
* PascalCase identifiers) and is asserted against a whitelist before interpolation — the caller
|
|
128
|
+
* never passes user input, so this is defense-in-depth.
|
|
151
129
|
*/
|
|
152
|
-
export function
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
throw new Error(`buildConsumeInviteSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
|
|
156
|
-
}
|
|
157
|
-
return (
|
|
158
|
-
`UPDATE ${qualifiedTable} ` +
|
|
159
|
-
`SET UseCount = UseCount + 1, ` +
|
|
160
|
-
`ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc')), ` +
|
|
161
|
-
`Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END ` +
|
|
162
|
-
`WHERE ID = $1 AND Status = 'Active' AND UseCount < MaxUses AND ExpiresAt > (now() AT TIME ZONE 'utc') ` +
|
|
163
|
-
`RETURNING ID;`
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
if (!QUALIFIED_TABLE_PATTERN.test(qualifiedTable)) {
|
|
167
|
-
throw new Error(`buildConsumeInviteSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
|
|
130
|
+
export function buildConsumeInvitePostgresSQL(qualifiedTable: string): string {
|
|
131
|
+
if (!QUALIFIED_TABLE_PATTERN_PG.test(qualifiedTable)) {
|
|
132
|
+
throw new Error(`buildConsumeInvitePostgresSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
|
|
168
133
|
}
|
|
169
134
|
return (
|
|
170
|
-
`DECLARE @consumed TABLE (ID UNIQUEIDENTIFIER); ` +
|
|
171
135
|
`UPDATE ${qualifiedTable} ` +
|
|
172
136
|
`SET UseCount = UseCount + 1, ` +
|
|
173
|
-
`ConsumedAt = COALESCE(ConsumedAt,
|
|
137
|
+
`ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc')), ` +
|
|
174
138
|
`Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END ` +
|
|
175
|
-
`
|
|
176
|
-
`
|
|
177
|
-
`SELECT ID FROM @consumed;`
|
|
139
|
+
`WHERE ID = $1 AND Status = 'Active' AND UseCount < MaxUses AND ExpiresAt > (now() AT TIME ZONE 'utc') ` +
|
|
140
|
+
`RETURNING ID;`
|
|
178
141
|
);
|
|
179
142
|
}
|
|
180
143
|
|
package/src/config.ts
CHANGED
|
@@ -230,6 +230,29 @@ const cacheSettingsSchema = z.object({
|
|
|
230
230
|
evictionSweepIntervalSeconds: z.number().optional().default(300),
|
|
231
231
|
/** Enable verbose cache logging (hits, misses, evictions). Default: false. */
|
|
232
232
|
verboseLogging: z.boolean().optional().default(false),
|
|
233
|
+
/**
|
|
234
|
+
* Entity names whose FULL ROW may ride along with a cache-invalidation broadcast.
|
|
235
|
+
*
|
|
236
|
+
* The cache-invalidation subscription is delivered to EVERY connected client with no per-user
|
|
237
|
+
* filter (see CacheInvalidationResolver), so any row named here is disclosed to every signed-in
|
|
238
|
+
* session, whatever row-level security or tenant scoping would otherwise apply to reading it.
|
|
239
|
+
*
|
|
240
|
+
* Defaults to `[]`: invalidation still carries the entity name and primary key, which is all a
|
|
241
|
+
* client needs to evict, and the client re-fetches through the normal read path where access
|
|
242
|
+
* control applies. Listing an entity re-enables the apply-in-place optimisation for it — correct
|
|
243
|
+
* only for reference data every signed-in user is allowed to read.
|
|
244
|
+
*
|
|
245
|
+
* What the re-fetch costs depends on the consumer. `ConversationEngine` re-reads the ONE record
|
|
246
|
+
* by primary key. `BaseEngine` (every engine subclass with `AutoRefresh`, the default) applies a
|
|
247
|
+
* remote save in place only when the row is present, so without it a remote save falls through to
|
|
248
|
+
* a full reload of each matching config — a `RunView` of that entity, not a keyed read. Remote
|
|
249
|
+
* deletes still apply in place from the primary key. Engine-cached reference entities that every
|
|
250
|
+
* signed-in user may read are the ones worth listing here.
|
|
251
|
+
*
|
|
252
|
+
* `['*']` opts every entity in, restoring the previous behaviour. Only safe on a deployment where
|
|
253
|
+
* every signed-in user may read every row of every entity.
|
|
254
|
+
*/
|
|
255
|
+
recordDataBroadcastEntities: z.array(z.string()).optional().default([]),
|
|
233
256
|
});
|
|
234
257
|
|
|
235
258
|
const loggingSettingsSchema = z.object({
|
package/src/context.ts
CHANGED
|
@@ -10,7 +10,8 @@ import { CloneUserForSessionContext } from './auth/sessionUserClone.js';
|
|
|
10
10
|
import { GetAPIKeyActingContextResolver } from './auth/actingContextResolver.js';
|
|
11
11
|
import { TokenExpiredError, AuthProviderFactory } from '@memberjunction/auth-providers';
|
|
12
12
|
import { authCache } from './cache.js';
|
|
13
|
-
import { userEmailMap, apiKey, mj_core_schema } from './config.js';
|
|
13
|
+
import { userEmailMap, apiKey, mj_core_schema, configInfo } from './config.js';
|
|
14
|
+
import { BuildPostgreSQLConnectionConfig, PostgreSQLReadOnlyPool, ResolvePostgreSQLEndpoint, ResolvePostgreSQLReadOnlyCredentials } from './postgresqlPoolSettings.js';
|
|
14
15
|
import { buildBoundaryLogPayload } from './logging/boundaryLogPayload.js';
|
|
15
16
|
import { StartupLogger } from './logging/StartupLogger.js';
|
|
16
17
|
import { DataSourceInfo, UserPayload } from './types.js';
|
|
@@ -818,15 +819,10 @@ async function createPerRequestProviders(
|
|
|
818
819
|
*/
|
|
819
820
|
async function createPostgresProvider(): Promise<DatabaseProviderBase> {
|
|
820
821
|
const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
|
|
821
|
-
const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
|
|
822
|
-
const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
|
|
823
|
-
const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
|
|
824
|
-
const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
|
|
825
|
-
const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
|
|
826
822
|
|
|
827
823
|
const pgProvider = new PostgreSQLDataProvider();
|
|
828
824
|
const pgConfig = new PostgreSQLProviderConfigData(
|
|
829
|
-
|
|
825
|
+
BuildPostgreSQLConnectionConfig(ResolvePostgreSQLEndpoint(), configInfo.databaseSettings, 'api'),
|
|
830
826
|
mj_core_schema,
|
|
831
827
|
0,
|
|
832
828
|
undefined,
|
|
@@ -846,39 +842,30 @@ async function createPostgresProvider(): Promise<DatabaseProviderBase> {
|
|
|
846
842
|
}
|
|
847
843
|
|
|
848
844
|
/**
|
|
849
|
-
*
|
|
850
|
-
*
|
|
845
|
+
* Creates a read-only PostgreSQL per-request provider on the read-only pool MJAPI opened at
|
|
846
|
+
* startup with the read-only login. Returns null when no read-only login is configured, so the
|
|
847
|
+
* caller never falls back to the read-write pool.
|
|
851
848
|
*/
|
|
852
849
|
async function tryCreateReadOnlyPostgresProvider(): Promise<DatabaseProviderBase | null> {
|
|
853
|
-
const
|
|
854
|
-
const
|
|
855
|
-
if (!
|
|
850
|
+
const readOnlyPool = PostgreSQLReadOnlyPool.Instance.Pool;
|
|
851
|
+
const credentials = ResolvePostgreSQLReadOnlyCredentials(configInfo);
|
|
852
|
+
if (!readOnlyPool || !credentials) {
|
|
856
853
|
return null;
|
|
857
854
|
}
|
|
858
855
|
|
|
859
856
|
try {
|
|
860
857
|
const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
|
|
861
|
-
const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
|
|
862
|
-
const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
|
|
863
|
-
const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
|
|
864
858
|
|
|
865
859
|
const roProvider = new PostgreSQLDataProvider();
|
|
866
860
|
const roConfig = new PostgreSQLProviderConfigData(
|
|
867
|
-
{
|
|
861
|
+
BuildPostgreSQLConnectionConfig({ ...ResolvePostgreSQLEndpoint(), ...credentials }, configInfo.databaseSettings, 'read-only'),
|
|
868
862
|
mj_core_schema,
|
|
869
863
|
0,
|
|
870
864
|
undefined,
|
|
871
865
|
undefined,
|
|
872
866
|
false,
|
|
873
867
|
);
|
|
874
|
-
|
|
875
|
-
const primaryProvider = Metadata.Provider as unknown as { DatabaseConnection?: import('pg').Pool }; // global-provider-ok: bootstrap (share primary provider's PG pool with the read-only provider)
|
|
876
|
-
if (primaryProvider?.DatabaseConnection) {
|
|
877
|
-
await roProvider.ConfigWithSharedPool(roConfig, primaryProvider.DatabaseConnection);
|
|
878
|
-
} else {
|
|
879
|
-
await roProvider.Config(roConfig);
|
|
880
|
-
}
|
|
881
|
-
|
|
868
|
+
await roProvider.ConfigWithSharedPool(roConfig, readOnlyPool);
|
|
882
869
|
return roProvider;
|
|
883
870
|
} catch (_err) {
|
|
884
871
|
return null;
|
|
@@ -1,7 +1,57 @@
|
|
|
1
1
|
import { Field, ObjectType, Resolver, Root, Subscription } from 'type-graphql';
|
|
2
|
+
import { Metadata, UserInfo } from '@memberjunction/core';
|
|
2
3
|
|
|
3
4
|
export const CACHE_INVALIDATION_TOPIC = 'CACHE_INVALIDATION';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Entities whose rows may ride along with an invalidation event. Empty until the server entry point
|
|
8
|
+
* supplies the configured list, so the default — including in any process that never configures it
|
|
9
|
+
* — is to broadcast nothing.
|
|
10
|
+
*/
|
|
11
|
+
let allowedRecordDataEntities: readonly string[] = [];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Supply the configured allowlist. Called once from the server entry point during bootstrap.
|
|
15
|
+
*
|
|
16
|
+
* INJECTED rather than read from `configInfo` here, deliberately. `config.ts` runs `loadConfig()` at
|
|
17
|
+
* module scope, so importing it from this module would drag full config validation into every
|
|
18
|
+
* import chain that touches the resolver — including unit tests, which have no server config and
|
|
19
|
+
* would fail at import with "Configuration validation failed". This module deliberately imports
|
|
20
|
+
* nothing from MJServer itself — only type-graphql and `@memberjunction/core` (for the entity
|
|
21
|
+
* permission check in the subscription filter), neither of which loads config.
|
|
22
|
+
*/
|
|
23
|
+
export function ConfigureRecordDataBroadcast(entities: readonly string[] | undefined | null): void {
|
|
24
|
+
// Normalised HERE, once, rather than at each comparison. The wildcard used to be matched raw
|
|
25
|
+
// (`includes('*')`) while entity names were trimmed and lowercased two lines below, so a
|
|
26
|
+
// hand-edited `[' * ']` matched neither branch and silently opted nothing in — a config that
|
|
27
|
+
// looks like it disables the gate while leaving it fully on. Empty entries are dropped so a
|
|
28
|
+
// stray `''` cannot match an entity whose name is somehow blank.
|
|
29
|
+
allowedRecordDataEntities = (entities ?? [])
|
|
30
|
+
.map((e) => e.trim().toLowerCase())
|
|
31
|
+
.filter((e) => e.length > 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* May the full row of `entityName` be broadcast alongside its cache-invalidation event?
|
|
36
|
+
*
|
|
37
|
+
* The subscription below is filtered only by ENTITY-level read permission, so anything included
|
|
38
|
+
* here is disclosed to every signed-in session that may read the entity at all — regardless of
|
|
39
|
+
* row-level security or any tenant scoping a consumer applies on the read path, neither of which
|
|
40
|
+
* this push traverses. The answer is therefore NO unless a deployment has explicitly opted the
|
|
41
|
+
* entity in via `cacheSettings.recordDataBroadcastEntities`.
|
|
42
|
+
*
|
|
43
|
+
* Callers that get `false` simply omit `recordData`; the event still carries the entity name and
|
|
44
|
+
* primary key, so clients evict and re-fetch through the normal access-controlled read path.
|
|
45
|
+
*
|
|
46
|
+
* Matching is exact (trimmed, case-insensitive) rather than substring: a loose match would let an
|
|
47
|
+
* allowlisted name drag in every entity whose name contains it.
|
|
48
|
+
*/
|
|
49
|
+
export function MayBroadcastRecordData(entityName: string): boolean {
|
|
50
|
+
if (allowedRecordDataEntities.length === 0) return false;
|
|
51
|
+
if (allowedRecordDataEntities.includes('*')) return true;
|
|
52
|
+
return allowedRecordDataEntities.includes(entityName.trim().toLowerCase());
|
|
53
|
+
}
|
|
54
|
+
|
|
5
55
|
@ObjectType()
|
|
6
56
|
export class CacheInvalidationNotification {
|
|
7
57
|
@Field(() => String)
|
|
@@ -40,15 +90,101 @@ export interface CacheInvalidationPayload {
|
|
|
40
90
|
recordData?: string;
|
|
41
91
|
}
|
|
42
92
|
|
|
93
|
+
/**
|
|
94
|
+
* What the subscription filter needs off the WS connection.
|
|
95
|
+
*
|
|
96
|
+
* Declared structurally rather than importing MJServer's `UserPayload`, which reaches `types.ts`
|
|
97
|
+
* and through it `auth/index.js` and config. Keeping this module's imports minimal is not fussiness
|
|
98
|
+
* — importing `configInfo` here is what previously broke every unit test that touched the resolver,
|
|
99
|
+
* and the import-surface test in this package exists to keep it that way.
|
|
100
|
+
*/
|
|
101
|
+
export interface CacheInvalidationFilterContext {
|
|
102
|
+
userPayload?: { userRecord?: unknown };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Should this subscriber hear about this event at all?
|
|
107
|
+
*
|
|
108
|
+
* Entity-level narrowing, raised in review: an event for an entity a session has no read
|
|
109
|
+
* permission on tells that session the record exists, its stable key, and when it changed — and
|
|
110
|
+
* the session could never have read it anyway, so nothing is lost by withholding it. This is a
|
|
111
|
+
* monotonic reduction in disclosure that needs no tenancy model, which is why it is here and the
|
|
112
|
+
* row-level question is not.
|
|
113
|
+
*
|
|
114
|
+
* What it does NOT close: two sessions that both hold read permission on an entity still see each
|
|
115
|
+
* other's keys, because what separates them is row-level. That predicate is tracked separately —
|
|
116
|
+
* see the class docblock below.
|
|
117
|
+
*
|
|
118
|
+
* `GetUserPermisions` is an in-memory match of the entity's permission rows against the user's
|
|
119
|
+
* roles, so this stays cheap enough to run per subscriber per event. It is not free: it runs for
|
|
120
|
+
* every connected socket on every save, so keep it free of I/O.
|
|
121
|
+
*/
|
|
122
|
+
export function cacheInvalidationFilter(data: {
|
|
123
|
+
payload: CacheInvalidationPayload;
|
|
124
|
+
context: CacheInvalidationFilterContext | undefined;
|
|
125
|
+
}): boolean {
|
|
126
|
+
const user = data.context?.userPayload?.userRecord as UserInfo | undefined;
|
|
127
|
+
// FAIL CLOSED on identity, matching `statusUpdatesFilter`. `onConnect` rejects a socket whose
|
|
128
|
+
// token does not validate, so a subscriber with no user record should not exist; if one does,
|
|
129
|
+
// it is the case least deserving of an unfiltered firehose.
|
|
130
|
+
if (!user) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const entityName = data.payload?.entityName;
|
|
135
|
+
if (!entityName) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A pub/sub subscription filter has no per-request provider to thread: it runs on the server's
|
|
140
|
+
// own metadata, which is the one place the entity's permission rows can be answered from.
|
|
141
|
+
const entity = new Metadata().EntityByName(entityName); // global-provider-ok: subscription filter — server-wide metadata, no request-scoped provider exists here
|
|
142
|
+
// DELIVER when the entity is unknown here, deliberately — the opposite of the choice above.
|
|
143
|
+
// A name absent from metadata cannot be permission-checked, and withholding would silently
|
|
144
|
+
// stop invalidating a legitimately cacheable entity, which is a correctness bug rather than a
|
|
145
|
+
// disclosure one. All it discloses is the name and key of something this server cannot
|
|
146
|
+
// describe.
|
|
147
|
+
if (!entity) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return entity.GetUserPermisions(user).CanRead;
|
|
152
|
+
}
|
|
153
|
+
|
|
43
154
|
@Resolver()
|
|
44
155
|
export class CacheInvalidationResolver {
|
|
45
156
|
/**
|
|
46
|
-
* Subscription that broadcasts cache invalidation events to
|
|
47
|
-
*
|
|
48
|
-
*
|
|
157
|
+
* Subscription that broadcasts cache invalidation events to connected clients, so cross-server
|
|
158
|
+
* cache invalidation reaches browsers.
|
|
159
|
+
*
|
|
160
|
+
* Delivery is filtered only by ENTITY-level read permission (see {@link cacheInvalidationFilter}).
|
|
161
|
+
* Every session that may read an entity at all still receives every event for it, from every
|
|
162
|
+
* other session and tenant.
|
|
163
|
+
*
|
|
164
|
+
* Because that narrowing stops well short of row level, `RecordData` is only populated for entities a
|
|
165
|
+
* deployment has opted in through `cacheSettings.recordDataBroadcastEntities` — see
|
|
166
|
+
* {@link MayBroadcastRecordData}, applied at both publish sites.
|
|
167
|
+
*
|
|
168
|
+
* `EntityName`, `PrimaryKeyValues`, `Action` and `Timestamp` ARE still broadcast to every
|
|
169
|
+
* session, and that is a disclosure, not a safe residue. An earlier version of this comment
|
|
170
|
+
* called them "safe to broadcast" on the grounds that reading the record still goes through
|
|
171
|
+
* the access-controlled path — true of the row's CONTENTS, and beside the point for the
|
|
172
|
+
* metadata itself. A session with no relationship to a record still learns that it exists,
|
|
173
|
+
* its stable key, and every time it changes; across entities it learns the cadence and volume
|
|
174
|
+
* of another tenant's activity.
|
|
175
|
+
*
|
|
176
|
+
* The key is kept deliberately, because consumers need it to re-read the record they were
|
|
177
|
+
* told about (see `ResolveEntityEventKey` in @memberjunction/core) and because the alternative
|
|
178
|
+
* — a coarse "something in this entity changed" — forces whole-entity invalidation on the
|
|
179
|
+
* hottest write paths. That is a trade, not an absence of cost. Narrowing it means filtering
|
|
180
|
+
* delivery per subscriber, which needs an authorization predicate cheap enough to run per
|
|
181
|
+
* subscriber per event; entity-level permissions (`EntityInfo.GetUserPermisions`, an in-memory
|
|
182
|
+
* role match) would cover part of it, row-level tenancy would not.
|
|
49
183
|
*/
|
|
50
184
|
@Subscription(() => CacheInvalidationNotification, {
|
|
51
185
|
topics: CACHE_INVALIDATION_TOPIC,
|
|
186
|
+
filter: (data: { payload: CacheInvalidationPayload; context: CacheInvalidationFilterContext | undefined }) =>
|
|
187
|
+
cacheInvalidationFilter(data),
|
|
52
188
|
})
|
|
53
189
|
cacheInvalidation(
|
|
54
190
|
@Root() payload: CacheInvalidationPayload
|
|
@@ -50,9 +50,16 @@ export class DeleteOptionsInput {
|
|
|
50
50
|
* schema-sync gate requires `DeleteOptionsInput` to carry every `EntityDeleteOptions` field;
|
|
51
51
|
* {@link DeleteOptionsInput.SanitizeFromWire} forces it back to false on every request. See
|
|
52
52
|
* that method for why.
|
|
53
|
+
*
|
|
54
|
+
* **Optional on the wire, on purpose.** This field did not exist in 5.51.x, and every other
|
|
55
|
+
* field here is required, so making it required too turned a 6.1 upgrade into a hard failure
|
|
56
|
+
* for every client that spells out `options___` on a delete (`Field
|
|
57
|
+
* "DeleteOptionsInput.SkipRecordChanges" of required type "Boolean!" was not provided`). A
|
|
58
|
+
* 5.51 client that omits it and a 6.1 client that sends `false` mean the same thing, and the
|
|
59
|
+
* server forces it to `false` regardless, so `null`/absent defaults to `false` here.
|
|
53
60
|
*/
|
|
54
|
-
@Field(() => Boolean)
|
|
55
|
-
SkipRecordChanges
|
|
61
|
+
@Field(() => Boolean, { nullable: true, defaultValue: false })
|
|
62
|
+
SkipRecordChanges?: boolean;
|
|
56
63
|
|
|
57
64
|
/**
|
|
58
65
|
* Strips wire-unreachable capabilities from client-supplied delete options.
|
|
@@ -40,7 +40,7 @@ import { SQLParser } from '@memberjunction/sql-parser';
|
|
|
40
40
|
import { PostgreSQLDialect, SQLServerDialect, type SQLParserDialect } from '@memberjunction/sql-dialect';
|
|
41
41
|
import { EncryptionEngine } from '@memberjunction/encryption';
|
|
42
42
|
import { PUSH_STATUS_UPDATES_TOPIC, publishStatusUpdate } from './PushStatusResolver.js';
|
|
43
|
-
import { CACHE_INVALIDATION_TOPIC } from './CacheInvalidationResolver.js';
|
|
43
|
+
import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
|
|
44
44
|
import { PubSubManager } from './PubSubManager.js';
|
|
45
45
|
import { FieldMapper } from '@memberjunction/graphql-dataprovider';
|
|
46
46
|
import { Subscription } from 'rxjs';
|
|
@@ -1449,16 +1449,24 @@ export class ResolverBase {
|
|
|
1449
1449
|
* Publishes a CACHE_INVALIDATION event to connected browser clients after a successful
|
|
1450
1450
|
* entity save or delete. Includes the originSessionId so the originating browser can
|
|
1451
1451
|
* skip redundant re-fetches (it already handled the event locally).
|
|
1452
|
+
*
|
|
1453
|
+
* The row itself rides along ONLY for entities opted in via
|
|
1454
|
+
* `cacheSettings.recordDataBroadcastEntities` — this event reaches every connected client
|
|
1455
|
+
* unfiltered, so the row would otherwise be readable by sessions that could not read the record.
|
|
1452
1456
|
*/
|
|
1453
1457
|
protected PublishCacheInvalidation(entityObject: BaseEntity, action: 'save' | 'delete', userPayload: UserPayload): void {
|
|
1458
|
+
const entityName = entityObject.EntityInfo.Name;
|
|
1454
1459
|
PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
|
|
1455
|
-
entityName
|
|
1460
|
+
entityName,
|
|
1456
1461
|
primaryKeyValues: JSON.stringify(entityObject.PrimaryKey.KeyValuePairs),
|
|
1457
1462
|
action,
|
|
1458
1463
|
sourceServerId: MJGlobal.Instance.ProcessUUID,
|
|
1459
1464
|
timestamp: new Date(),
|
|
1460
1465
|
originSessionId: userPayload?.sessionId || null,
|
|
1461
|
-
recordData:
|
|
1466
|
+
recordData:
|
|
1467
|
+
action === 'save' && MayBroadcastRecordData(entityName)
|
|
1468
|
+
? JSON.stringify(entityObject.GetAll())
|
|
1469
|
+
: undefined,
|
|
1462
1470
|
});
|
|
1463
1471
|
}
|
|
1464
1472
|
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,8 @@ import { WebSocketServer } from 'ws';
|
|
|
31
31
|
import { RealtimeProxyServer } from './realtimeProxy/RealtimeProxyServer.js';
|
|
32
32
|
import buildApolloServer from './apolloServer/index.js';
|
|
33
33
|
import { configInfo, configFilePath, dbDatabase, dbHost, dbPort, dbUsername, graphqlPort, graphqlRootPath, mj_core_schema, websiteRunFromPackage, RESTApiOptions } from './config.js';
|
|
34
|
+
import { TranslateBracketsToPG } from './postgresqlCompat.js';
|
|
35
|
+
import { BuildPostgreSQLConnectionConfig, DescribeReadOnlyLoginOverreach, PostgreSQLReadOnlyPool, ResolvePostgreSQLEndpoint, ResolvePostgreSQLReadOnlyCredentials, ToPGPoolConfig } from './postgresqlPoolSettings.js';
|
|
34
36
|
import { default as jwt } from 'jsonwebtoken';
|
|
35
37
|
import { contextFunction, createUnifiedAuthMiddleware, getUserPayload } from './context.js';
|
|
36
38
|
import { UserPayload } from './types.js';
|
|
@@ -67,7 +69,7 @@ import { RegisterRSUProgressBridge } from './integration/RSUProgressBridge.js';
|
|
|
67
69
|
import { ClientToolRequestManager, AgentRunWatchdog } from '@memberjunction/ai-agents';
|
|
68
70
|
import { SessionJanitor } from './agentSessions/index.js';
|
|
69
71
|
import { StartTaskGraphDispatcher } from './services/StartTaskGraphDispatcher.js';
|
|
70
|
-
import { CACHE_INVALIDATION_TOPIC } from './generic/CacheInvalidationResolver.js';
|
|
72
|
+
import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData, ConfigureRecordDataBroadcast } from './generic/CacheInvalidationResolver.js';
|
|
71
73
|
import { ConnectorFactory, IntegrationEngine, IntegrationSyncOptions } from '@memberjunction/integration-engine';
|
|
72
74
|
import { CronExpressionHelper } from '@memberjunction/scheduling-engine';
|
|
73
75
|
import {
|
|
@@ -316,25 +318,13 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
316
318
|
startupLog.BeginPhase('Connecting to database');
|
|
317
319
|
startupLog.LogIf('verbose', 'Database type: PostgreSQL');
|
|
318
320
|
const pg = await import('pg');
|
|
319
|
-
const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
|
|
321
|
+
const { PostgreSQLDataProvider, PostgreSQLProviderConfigData, MJPostgresTypes } = await import('@memberjunction/postgresql-dataprovider');
|
|
320
322
|
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
const pgPool = new pg.default.Pool({
|
|
328
|
-
host: pgHost,
|
|
329
|
-
port: pgPort,
|
|
330
|
-
user: pgUser,
|
|
331
|
-
password: pgPass,
|
|
332
|
-
database: pgDatabase,
|
|
333
|
-
max: configInfo.databaseSettings.connectionPool?.max ?? 50,
|
|
334
|
-
min: configInfo.databaseSettings.connectionPool?.min ?? 5,
|
|
335
|
-
idleTimeoutMillis: configInfo.databaseSettings.connectionPool?.idleTimeoutMillis ?? 30000,
|
|
336
|
-
connectionTimeoutMillis: configInfo.databaseSettings.connectionPool?.acquireTimeoutMillis ?? 30000,
|
|
337
|
-
});
|
|
323
|
+
const pgEndpoint = ResolvePostgreSQLEndpoint();
|
|
324
|
+
const { Host: pgHost, Port: pgPort, User: pgUser, Database: pgDatabase } = pgEndpoint;
|
|
325
|
+
// Every API pool carries the statement and idle-in-transaction timeouts from connection #1.
|
|
326
|
+
const pgConnectionConfig = BuildPostgreSQLConnectionConfig(pgEndpoint, configInfo.databaseSettings, 'api');
|
|
327
|
+
const pgPool = new pg.default.Pool(ToPGPoolConfig(pgConnectionConfig));
|
|
338
328
|
|
|
339
329
|
// Verify connection
|
|
340
330
|
const testClient = await pgPool.connect();
|
|
@@ -342,6 +332,28 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
342
332
|
testClient.release();
|
|
343
333
|
startupLog.LogIf('verbose', `PostgreSQL pool connected to ${pgHost}:${pgPort}/${pgDatabase}`);
|
|
344
334
|
|
|
335
|
+
// A read-only pool opened with the read-only login, as SQL Server has. Read-only per-request
|
|
336
|
+
// providers (TestQuerySQL and other caller-supplied SQL) share it instead of the primary pool.
|
|
337
|
+
const pgReadOnlyCredentials = ResolvePostgreSQLReadOnlyCredentials(configInfo);
|
|
338
|
+
if (pgReadOnlyCredentials) {
|
|
339
|
+
// A pool a provider runs on must carry the provider's type parsers, so BIGINT and NUMERIC
|
|
340
|
+
// come back as numbers here as they do on the provider's own pool.
|
|
341
|
+
const readOnlyPgPool = new pg.default.Pool({
|
|
342
|
+
...ToPGPoolConfig(BuildPostgreSQLConnectionConfig({ ...pgEndpoint, ...pgReadOnlyCredentials }, configInfo.databaseSettings, 'read-only')),
|
|
343
|
+
types: MJPostgresTypes,
|
|
344
|
+
});
|
|
345
|
+
const readOnlyTestClient = await readOnlyPgPool.connect();
|
|
346
|
+
try {
|
|
347
|
+
for (const warning of await DescribeReadOnlyLoginOverreach(readOnlyTestClient, mj_core_schema)) {
|
|
348
|
+
LogStatus(`WARNING: ${warning}`);
|
|
349
|
+
}
|
|
350
|
+
} finally {
|
|
351
|
+
readOnlyTestClient.release();
|
|
352
|
+
}
|
|
353
|
+
PostgreSQLReadOnlyPool.Instance.Pool = readOnlyPgPool;
|
|
354
|
+
startupLog.LogIf('verbose', 'Read-only PostgreSQL pool has been initialized.');
|
|
355
|
+
}
|
|
356
|
+
|
|
345
357
|
// Create a DataSourceInfo with a MSSQL-compatible wrapper around pg.Pool
|
|
346
358
|
// This allows existing code (types, util, context) to work without changes
|
|
347
359
|
const mssqlCompatPool = createMSSQLCompatPool(pgPool);
|
|
@@ -355,15 +367,6 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
355
367
|
}));
|
|
356
368
|
|
|
357
369
|
// Set up the PostgreSQL provider
|
|
358
|
-
const pgConnectionConfig = {
|
|
359
|
-
Host: pgHost,
|
|
360
|
-
Port: pgPort,
|
|
361
|
-
Database: pgDatabase,
|
|
362
|
-
User: pgUser,
|
|
363
|
-
Password: pgPass,
|
|
364
|
-
MaxConnections: configInfo.databaseSettings.connectionPool?.max ?? 50,
|
|
365
|
-
MinConnections: configInfo.databaseSettings.connectionPool?.min ?? 5,
|
|
366
|
-
};
|
|
367
370
|
const pgConfigData = new PostgreSQLProviderConfigData(
|
|
368
371
|
pgConnectionConfig,
|
|
369
372
|
mj_core_schema,
|
|
@@ -397,7 +400,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
397
400
|
if (poolAny._pgPool) {
|
|
398
401
|
const thePgPool = poolAny._pgPool as import('pg').Pool;
|
|
399
402
|
// Translate SQL Server bracket syntax to PostgreSQL double-quote syntax
|
|
400
|
-
const pgQuery =
|
|
403
|
+
const pgQuery = TranslateBracketsToPG(query);
|
|
401
404
|
const result = await thePgPool.query(pgQuery);
|
|
402
405
|
return result.rows;
|
|
403
406
|
}
|
|
@@ -416,21 +419,21 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
416
419
|
const pgCodegenPass = process.env.CODEGEN_DB_PASSWORD;
|
|
417
420
|
if (pgCodegenUser && pgCodegenPass) {
|
|
418
421
|
try {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
422
|
+
// CodeGen and DDL work runs long statements across every entity, so this pool gets the
|
|
423
|
+
// long CodeGen timeout rather than the API one, as the SQL Server CodeGen pool does.
|
|
424
|
+
const codegenPgConnectionConfig = BuildPostgreSQLConnectionConfig(
|
|
425
|
+
{ ...pgEndpoint, User: pgCodegenUser, Password: pgCodegenPass },
|
|
426
|
+
configInfo.databaseSettings,
|
|
427
|
+
'codegen',
|
|
428
|
+
);
|
|
429
|
+
const codegenPgPool = new pg.default.Pool(ToPGPoolConfig(codegenPgConnectionConfig));
|
|
427
430
|
const codegenTestClient = await codegenPgPool.connect();
|
|
428
431
|
await codegenTestClient.query('SELECT 1');
|
|
429
432
|
codegenTestClient.release();
|
|
430
433
|
|
|
431
434
|
const { RuntimeSchemaManager } = await import('@memberjunction/schema-engine');
|
|
432
435
|
const codegenPgConfigData = new PostgreSQLProviderConfigData(
|
|
433
|
-
|
|
436
|
+
codegenPgConnectionConfig,
|
|
434
437
|
mj_core_schema,
|
|
435
438
|
cacheRefreshInterval / 1000, // ms → seconds
|
|
436
439
|
);
|
|
@@ -896,6 +899,11 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
896
899
|
// publish hook above so the first RSU event also reaches live subscribers.
|
|
897
900
|
RegisterRSUProgressBridge();
|
|
898
901
|
|
|
902
|
+
// Hand the resolver its allowlist before anything can publish. It cannot read configInfo itself:
|
|
903
|
+
// config.ts loads and validates at module scope, so importing it there would pull full config
|
|
904
|
+
// validation into every import chain that touches the resolver, unit tests included.
|
|
905
|
+
ConfigureRecordDataBroadcast(configInfo.cacheSettings?.recordDataBroadcastEntities);
|
|
906
|
+
|
|
899
907
|
// Global listener: broadcast CACHE_INVALIDATION to all browser clients whenever
|
|
900
908
|
// ANY BaseEntity save/delete occurs on this server — regardless of whether it
|
|
901
909
|
// originated from a GraphQL mutation or internal server-side code (agents, actions,
|
|
@@ -905,14 +913,21 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
905
913
|
if (event.event === MJEventType.ComponentEvent && event.eventCode === BaseEntity.BaseEventCode) {
|
|
906
914
|
const beEvent = event.args as BaseEntityEvent;
|
|
907
915
|
if (beEvent.type === 'save' || beEvent.type === 'delete') {
|
|
916
|
+
const entityName = beEvent.baseEntity.EntityInfo.Name;
|
|
908
917
|
PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
|
|
909
|
-
entityName
|
|
918
|
+
entityName,
|
|
910
919
|
primaryKeyValues: JSON.stringify(beEvent.baseEntity.PrimaryKey.KeyValuePairs),
|
|
911
920
|
action: beEvent.type,
|
|
912
921
|
sourceServerId: MJGlobal.Instance.ProcessUUID,
|
|
913
922
|
timestamp: new Date(),
|
|
914
923
|
originSessionId: null,
|
|
915
|
-
|
|
924
|
+
// Opt-in only: this event reaches every connected client unfiltered, and this listener
|
|
925
|
+
// fires for server-internal saves too (agents, actions, orchestrator), which are exactly
|
|
926
|
+
// the ones no browser session asked for.
|
|
927
|
+
recordData:
|
|
928
|
+
beEvent.type === 'save' && MayBroadcastRecordData(entityName)
|
|
929
|
+
? JSON.stringify(beEvent.baseEntity.GetAll())
|
|
930
|
+
: undefined,
|
|
916
931
|
});
|
|
917
932
|
}
|
|
918
933
|
}
|
|
@@ -2067,11 +2082,3 @@ function createMSSQLCompatPool(pgPool: import('pg').Pool): sql.ConnectionPool {
|
|
|
2067
2082
|
return wrapper as unknown as sql.ConnectionPool;
|
|
2068
2083
|
}
|
|
2069
2084
|
|
|
2070
|
-
/**
|
|
2071
|
-
* Translates SQL Server bracket-quoted identifiers to PostgreSQL double-quoted identifiers.
|
|
2072
|
-
* Converts [schema].[table] to "schema"."table" and handles common T-SQL patterns.
|
|
2073
|
-
*/
|
|
2074
|
-
function translateBracketsToPG(sql: string): string {
|
|
2075
|
-
// Replace [identifier] with "identifier"
|
|
2076
|
-
return sql.replace(/\[([^\]]+)\]/g, '"$1"');
|
|
2077
|
-
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { LexSQL } from '@memberjunction/sql-parser';
|
|
2
|
+
import { SQLServerDialect } from '@memberjunction/sql-dialect';
|
|
3
|
+
|
|
4
|
+
const sqlServer = new SQLServerDialect();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Translates SQL Server bracket-quoted identifiers (`[Name]`) to PostgreSQL double-quoted ones
|
|
8
|
+
* (`"Name"`), for SQL written in SQL Server syntax that runs on PostgreSQL. Only identifiers
|
|
9
|
+
* change: brackets inside string literals and comments are left as written. A `]]` in a name
|
|
10
|
+
* becomes `]`, and a `"` in a name is doubled.
|
|
11
|
+
*/
|
|
12
|
+
export function TranslateBracketsToPG(sql: string): string {
|
|
13
|
+
return LexSQL(sql, sqlServer)
|
|
14
|
+
.map(t => t.Kind === 'identifier' && t.Text.startsWith('[') ? toDoubleQuoted(t.Text) : t.Text)
|
|
15
|
+
.join('');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function toDoubleQuoted(bracketed: string): string {
|
|
19
|
+
const name = bracketed.slice(1, bracketed.endsWith(']') ? -1 : undefined).replace(/]]/g, ']');
|
|
20
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
21
|
+
}
|