@open-mercato/shared 0.6.7-develop.6600.1.49a5df927f → 0.6.7-develop.6604.1.2c49816dff
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/dist/lib/crud/cache.js +3 -5
- package/dist/lib/crud/cache.js.map +2 -2
- package/dist/lib/crud/factory.js +4 -3
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/ids.js +11 -2
- package/dist/lib/crud/ids.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/cache.test.ts +53 -1
- package/src/lib/crud/__tests__/ids.test.ts +31 -1
- package/src/lib/crud/cache.ts +8 -5
- package/src/lib/crud/factory.ts +4 -3
- package/src/lib/crud/ids.ts +22 -1
package/dist/lib/crud/ids.js
CHANGED
|
@@ -38,8 +38,16 @@ function parseIdsParam(raw, maxIds = MAX_IDS_PER_REQUEST) {
|
|
|
38
38
|
const parsed = normalizeIdList(raw.split(","));
|
|
39
39
|
return parsed.slice(0, safeMax);
|
|
40
40
|
}
|
|
41
|
-
function
|
|
42
|
-
|
|
41
|
+
function isIdsParamProvided(raw) {
|
|
42
|
+
return typeof raw === "string" && raw.trim().length > 0;
|
|
43
|
+
}
|
|
44
|
+
function mergeIdFilter(existingFilters, parsedIds, options) {
|
|
45
|
+
if (parsedIds.length === 0) {
|
|
46
|
+
if (options?.idsParamProvided) {
|
|
47
|
+
return { ...existingFilters, id: { $in: [] } };
|
|
48
|
+
}
|
|
49
|
+
return existingFilters;
|
|
50
|
+
}
|
|
43
51
|
const existingFilter = existingFilters.id;
|
|
44
52
|
const existingIds = readExistingIds(existingFilter);
|
|
45
53
|
if (!existingIds) {
|
|
@@ -57,6 +65,7 @@ function mergeIdFilter(existingFilters, parsedIds) {
|
|
|
57
65
|
}
|
|
58
66
|
export {
|
|
59
67
|
MAX_IDS_PER_REQUEST,
|
|
68
|
+
isIdsParamProvided,
|
|
60
69
|
mergeIdFilter,
|
|
61
70
|
parseIdsParam
|
|
62
71
|
};
|
package/dist/lib/crud/ids.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/crud/ids.ts"],
|
|
4
|
-
"sourcesContent": ["import type { Where } from '@open-mercato/shared/lib/query/types'\n\nexport const MAX_IDS_PER_REQUEST = 200\n\nconst UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n\nfunction isUuid(value: unknown): value is string {\n return typeof value === 'string' && UUID_REGEX.test(value)\n}\n\nfunction normalizeIdList(values: string[]): string[] {\n if (values.length === 0) return values\n const deduped = new Set<string>()\n for (const value of values) {\n const trimmed = value.trim()\n if (!trimmed || !isUuid(trimmed)) continue\n deduped.add(trimmed)\n }\n return Array.from(deduped)\n}\n\nfunction readExistingIds(filter: unknown): string[] | null {\n if (typeof filter === 'string') {\n return isUuid(filter) ? [filter] : null\n }\n if (Array.isArray(filter)) {\n return normalizeIdList(\n filter.filter((value): value is string => typeof value === 'string'),\n )\n }\n if (!filter || typeof filter !== 'object') return null\n\n const operators = filter as Record<string, unknown>\n if (isUuid(operators.$eq)) return [operators.$eq]\n\n if (Array.isArray(operators.$in)) {\n return normalizeIdList(\n operators.$in.filter((value): value is string => typeof value === 'string'),\n )\n }\n\n return null\n}\n\nexport function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST): string[] {\n if (typeof raw !== 'string' || raw.trim().length === 0) return []\n const safeMax = Number.isFinite(maxIds) && maxIds > 0 ? Math.floor(maxIds) : MAX_IDS_PER_REQUEST\n const parsed = normalizeIdList(raw.split(','))\n return parsed.slice(0, safeMax)\n}\n\nexport function mergeIdFilter<Fields extends Record<string, unknown>>(\n existingFilters: Where<Fields>,\n parsedIds: string[],\n): Where<Fields> {\n if (parsedIds.length === 0) return existingFilters\n\n const existingFilter = (existingFilters as Record<string, unknown>).id\n const existingIds = readExistingIds(existingFilter)\n\n if (!existingIds) {\n // No existing narrowing \u2014 safe to install the user-supplied `$in`.\n if (existingFilter === undefined || existingFilter === null) {\n return { ...existingFilters, id: { $in: parsedIds } }\n }\n // Existing `id` filter is in a shape we do not recognise. Fail closed:\n // preserve the existing filter instead of widening to `parsedIds`. Adding\n // a new recognised shape to `readExistingIds` is preferable to silently\n // dropping a narrowing that another caller put in place.\n return existingFilters\n }\n\n const allowed = new Set(parsedIds)\n const intersection = existingIds.filter((id) => allowed.has(id))\n return {\n ...existingFilters,\n id: { $in: intersection },\n }\n}\n"],
|
|
5
|
-
"mappings": "AAEO,MAAM,sBAAsB;AAEnC,MAAM,aAAa;AAEnB,SAAS,OAAO,OAAiC;AAC/C,SAAO,OAAO,UAAU,YAAY,WAAW,KAAK,KAAK;AAC3D;AAEA,SAAS,gBAAgB,QAA4B;AACnD,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,QAAM,UAAU,oBAAI,IAAY;AAChC,aAAW,SAAS,QAAQ;AAC1B,UAAM,UAAU,MAAM,KAAK;AAC3B,QAAI,CAAC,WAAW,CAAC,OAAO,OAAO,EAAG;AAClC,YAAQ,IAAI,OAAO;AAAA,EACrB;AACA,SAAO,MAAM,KAAK,OAAO;AAC3B;AAEA,SAAS,gBAAgB,QAAkC;AACzD,MAAI,OAAO,WAAW,UAAU;AAC9B,WAAO,OAAO,MAAM,IAAI,CAAC,MAAM,IAAI;AAAA,EACrC;AACA,MAAI,MAAM,QAAQ,MAAM,GAAG;AACzB,WAAO;AAAA,MACL,OAAO,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAAA,IACrE;AAAA,EACF;AACA,MAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAElD,QAAM,YAAY;AAClB,MAAI,OAAO,UAAU,GAAG,EAAG,QAAO,CAAC,UAAU,GAAG;AAEhD,MAAI,MAAM,QAAQ,UAAU,GAAG,GAAG;AAChC,WAAO;AAAA,MACL,UAAU,IAAI,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAAA,IAC5E;AAAA,EACF;AAEA,SAAO;AACT;AAEO,SAAS,cAAc,KAAc,SAAiB,qBAA+B;AAC1F,MAAI,OAAO,QAAQ,YAAY,IAAI,KAAK,EAAE,WAAW,EAAG,QAAO,CAAC;AAChE,QAAM,UAAU,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,KAAK,MAAM,MAAM,IAAI;AAC7E,QAAM,SAAS,gBAAgB,IAAI,MAAM,GAAG,CAAC;AAC7C,SAAO,OAAO,MAAM,GAAG,OAAO;AAChC;AAEO,SAAS,cACd,iBACA,
|
|
4
|
+
"sourcesContent": ["import type { Where } from '@open-mercato/shared/lib/query/types'\n\nexport const MAX_IDS_PER_REQUEST = 200\n\nconst UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n\nfunction isUuid(value: unknown): value is string {\n return typeof value === 'string' && UUID_REGEX.test(value)\n}\n\nfunction normalizeIdList(values: string[]): string[] {\n if (values.length === 0) return values\n const deduped = new Set<string>()\n for (const value of values) {\n const trimmed = value.trim()\n if (!trimmed || !isUuid(trimmed)) continue\n deduped.add(trimmed)\n }\n return Array.from(deduped)\n}\n\nfunction readExistingIds(filter: unknown): string[] | null {\n if (typeof filter === 'string') {\n return isUuid(filter) ? [filter] : null\n }\n if (Array.isArray(filter)) {\n return normalizeIdList(\n filter.filter((value): value is string => typeof value === 'string'),\n )\n }\n if (!filter || typeof filter !== 'object') return null\n\n const operators = filter as Record<string, unknown>\n if (isUuid(operators.$eq)) return [operators.$eq]\n\n if (Array.isArray(operators.$in)) {\n return normalizeIdList(\n operators.$in.filter((value): value is string => typeof value === 'string'),\n )\n }\n\n return null\n}\n\nexport function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST): string[] {\n if (typeof raw !== 'string' || raw.trim().length === 0) return []\n const safeMax = Number.isFinite(maxIds) && maxIds > 0 ? Math.floor(maxIds) : MAX_IDS_PER_REQUEST\n const parsed = normalizeIdList(raw.split(','))\n return parsed.slice(0, safeMax)\n}\n\n/**\n * Whether an `?ids=` param was supplied at all (a non-empty string), regardless\n * of whether any value survived UUID validation. Lets a caller tell \"no ids\n * filter requested\" apart from \"ids filter requested but every value was\n * malformed\" \u2014 the latter must match nothing, not fall back to the full list\n * (#4143 Finding 3).\n */\nexport function isIdsParamProvided(raw: unknown): boolean {\n return typeof raw === 'string' && raw.trim().length > 0\n}\n\nexport function mergeIdFilter<Fields extends Record<string, unknown>>(\n existingFilters: Where<Fields>,\n parsedIds: string[],\n options?: { idsParamProvided?: boolean },\n): Where<Fields> {\n if (parsedIds.length === 0) {\n // The `?ids=` param was supplied but nothing survived UUID validation.\n // Match nothing, mirroring a valid-but-unknown id returning zero rows,\n // rather than silently dropping the filter and returning the full list\n // (record-count side channel \u2014 #4143 Finding 3).\n if (options?.idsParamProvided) {\n return { ...existingFilters, id: { $in: [] } }\n }\n return existingFilters\n }\n\n const existingFilter = (existingFilters as Record<string, unknown>).id\n const existingIds = readExistingIds(existingFilter)\n\n if (!existingIds) {\n // No existing narrowing \u2014 safe to install the user-supplied `$in`.\n if (existingFilter === undefined || existingFilter === null) {\n return { ...existingFilters, id: { $in: parsedIds } }\n }\n // Existing `id` filter is in a shape we do not recognise. Fail closed:\n // preserve the existing filter instead of widening to `parsedIds`. Adding\n // a new recognised shape to `readExistingIds` is preferable to silently\n // dropping a narrowing that another caller put in place.\n return existingFilters\n }\n\n const allowed = new Set(parsedIds)\n const intersection = existingIds.filter((id) => allowed.has(id))\n return {\n ...existingFilters,\n id: { $in: intersection },\n }\n}\n"],
|
|
5
|
+
"mappings": "AAEO,MAAM,sBAAsB;AAEnC,MAAM,aAAa;AAEnB,SAAS,OAAO,OAAiC;AAC/C,SAAO,OAAO,UAAU,YAAY,WAAW,KAAK,KAAK;AAC3D;AAEA,SAAS,gBAAgB,QAA4B;AACnD,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,QAAM,UAAU,oBAAI,IAAY;AAChC,aAAW,SAAS,QAAQ;AAC1B,UAAM,UAAU,MAAM,KAAK;AAC3B,QAAI,CAAC,WAAW,CAAC,OAAO,OAAO,EAAG;AAClC,YAAQ,IAAI,OAAO;AAAA,EACrB;AACA,SAAO,MAAM,KAAK,OAAO;AAC3B;AAEA,SAAS,gBAAgB,QAAkC;AACzD,MAAI,OAAO,WAAW,UAAU;AAC9B,WAAO,OAAO,MAAM,IAAI,CAAC,MAAM,IAAI;AAAA,EACrC;AACA,MAAI,MAAM,QAAQ,MAAM,GAAG;AACzB,WAAO;AAAA,MACL,OAAO,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAAA,IACrE;AAAA,EACF;AACA,MAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAElD,QAAM,YAAY;AAClB,MAAI,OAAO,UAAU,GAAG,EAAG,QAAO,CAAC,UAAU,GAAG;AAEhD,MAAI,MAAM,QAAQ,UAAU,GAAG,GAAG;AAChC,WAAO;AAAA,MACL,UAAU,IAAI,OAAO,CAAC,UAA2B,OAAO,UAAU,QAAQ;AAAA,IAC5E;AAAA,EACF;AAEA,SAAO;AACT;AAEO,SAAS,cAAc,KAAc,SAAiB,qBAA+B;AAC1F,MAAI,OAAO,QAAQ,YAAY,IAAI,KAAK,EAAE,WAAW,EAAG,QAAO,CAAC;AAChE,QAAM,UAAU,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,KAAK,MAAM,MAAM,IAAI;AAC7E,QAAM,SAAS,gBAAgB,IAAI,MAAM,GAAG,CAAC;AAC7C,SAAO,OAAO,MAAM,GAAG,OAAO;AAChC;AASO,SAAS,mBAAmB,KAAuB;AACxD,SAAO,OAAO,QAAQ,YAAY,IAAI,KAAK,EAAE,SAAS;AACxD;AAEO,SAAS,cACd,iBACA,WACA,SACe;AACf,MAAI,UAAU,WAAW,GAAG;AAK1B,QAAI,SAAS,kBAAkB;AAC7B,aAAO,EAAE,GAAG,iBAAiB,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE;AAAA,IAC/C;AACA,WAAO;AAAA,EACT;AAEA,QAAM,iBAAkB,gBAA4C;AACpE,QAAM,cAAc,gBAAgB,cAAc;AAElD,MAAI,CAAC,aAAa;AAEhB,QAAI,mBAAmB,UAAa,mBAAmB,MAAM;AAC3D,aAAO,EAAE,GAAG,iBAAiB,IAAI,EAAE,KAAK,UAAU,EAAE;AAAA,IACtD;AAKA,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,IAAI,IAAI,SAAS;AACjC,QAAM,eAAe,YAAY,OAAO,CAAC,OAAO,QAAQ,IAAI,EAAE,CAAC;AAC/D,SAAO;AAAA,IACL,GAAG;AAAA,IACH,IAAI,EAAE,KAAK,aAAa;AAAA,EAC1B;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6604.1.2c49816dff'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6604.1.2c49816dff",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"@mikro-orm/core": "^7.1.5",
|
|
98
98
|
"@mikro-orm/decorators": "^7.1.5",
|
|
99
99
|
"@mikro-orm/postgresql": "^7.1.5",
|
|
100
|
-
"@open-mercato/cache": "0.6.7-develop.
|
|
100
|
+
"@open-mercato/cache": "0.6.7-develop.6604.1.2c49816dff",
|
|
101
101
|
"@types/sanitize-html": "^2.16.1",
|
|
102
102
|
"dotenv": "^17.4.2",
|
|
103
103
|
"pino": "^10.3.1",
|
|
@@ -2,7 +2,7 @@ jest.mock('@open-mercato/cache', () => ({
|
|
|
2
2
|
runWithCacheTenant: async <T>(_tenant: string | null, fn: () => Promise<T> | T) => fn(),
|
|
3
3
|
}))
|
|
4
4
|
|
|
5
|
-
import { deriveResourceFromCommandId } from '@open-mercato/shared/lib/crud/cache'
|
|
5
|
+
import { deriveResourceFromCommandId, invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'
|
|
6
6
|
|
|
7
7
|
describe('deriveResourceFromCommandId — irregular plurals (#2072)', () => {
|
|
8
8
|
it('singularises "people" to "person"', () => {
|
|
@@ -62,3 +62,55 @@ describe('deriveResourceFromCommandId — irregular plurals (#2072)', () => {
|
|
|
62
62
|
expect(deriveResourceFromCommandId('singletonly')).toBeNull()
|
|
63
63
|
})
|
|
64
64
|
})
|
|
65
|
+
|
|
66
|
+
describe('invalidateCrudCache — tenant-level (org:null) collection flush (#2919)', () => {
|
|
67
|
+
const ORIGINAL_FLAG = process.env.ENABLE_CRUD_API_CACHE
|
|
68
|
+
|
|
69
|
+
beforeAll(() => {
|
|
70
|
+
process.env.ENABLE_CRUD_API_CACHE = 'true'
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
afterAll(() => {
|
|
74
|
+
if (ORIGINAL_FLAG === undefined) delete process.env.ENABLE_CRUD_API_CACHE
|
|
75
|
+
else process.env.ENABLE_CRUD_API_CACHE = ORIGINAL_FLAG
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
function makeContainer(deleteByTags: jest.Mock) {
|
|
79
|
+
const cache = { get: jest.fn(), set: jest.fn(), deleteByTags }
|
|
80
|
+
return { resolve: (name: string) => (name === 'cache' ? cache : null) } as never
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it('flushes the org:null collection tag even when the write carries an actor org', async () => {
|
|
84
|
+
// Roles (orgField: null) cache their list under org:null, but the command bus
|
|
85
|
+
// resolves a write's organizationId from the actor's auth context. Without the
|
|
86
|
+
// org:null tag the cache would only expire on TTL — see TC-UNDO-001 / #2919.
|
|
87
|
+
const deleteByTags = jest.fn(async () => 0)
|
|
88
|
+
await invalidateCrudCache(
|
|
89
|
+
makeContainer(deleteByTags),
|
|
90
|
+
'auth.role',
|
|
91
|
+
{ id: 'role-1', organizationId: 'org-9', tenantId: 'tenant-1' },
|
|
92
|
+
'tenant-1',
|
|
93
|
+
'test:org-mismatch',
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
expect(deleteByTags).toHaveBeenCalledTimes(1)
|
|
97
|
+
const tags = deleteByTags.mock.calls[0][0] as string[]
|
|
98
|
+
expect(tags).toContain('crud:auth.role:tenant:tenant-1:org:null:collection')
|
|
99
|
+
expect(tags).toContain('crud:auth.role:tenant:tenant-1:org:org-9:collection')
|
|
100
|
+
expect(tags).toContain('crud:auth.role:tenant:tenant-1:record:role-1')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('still flushes the org:null collection tag for org-agnostic writes', async () => {
|
|
104
|
+
const deleteByTags = jest.fn(async () => 0)
|
|
105
|
+
await invalidateCrudCache(
|
|
106
|
+
makeContainer(deleteByTags),
|
|
107
|
+
'auth.role',
|
|
108
|
+
{ id: 'role-2', organizationId: null, tenantId: 'tenant-1' },
|
|
109
|
+
'tenant-1',
|
|
110
|
+
'test:org-null',
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const tags = deleteByTags.mock.calls[0][0] as string[]
|
|
114
|
+
expect(tags).toContain('crud:auth.role:tenant:tenant-1:org:null:collection')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MAX_IDS_PER_REQUEST, mergeIdFilter, parseIdsParam } from '@open-mercato/shared/lib/crud/ids'
|
|
1
|
+
import { MAX_IDS_PER_REQUEST, mergeIdFilter, parseIdsParam, isIdsParamProvided } from '@open-mercato/shared/lib/crud/ids'
|
|
2
2
|
|
|
3
3
|
describe('crud ids helpers', () => {
|
|
4
4
|
const idA = '550e8400-e29b-41d4-a716-446655440001'
|
|
@@ -109,4 +109,34 @@ describe('crud ids helpers', () => {
|
|
|
109
109
|
id: { $in: [idA] },
|
|
110
110
|
})
|
|
111
111
|
})
|
|
112
|
+
|
|
113
|
+
// #4143 Finding 3: `?ids=<garbage>` parsed to [] and silently dropped the
|
|
114
|
+
// filter, returning the full list — a record-count side channel. A supplied
|
|
115
|
+
// but fully-invalid ids param must match nothing, like a valid-unknown UUID.
|
|
116
|
+
it('isIdsParamProvided distinguishes a supplied param from an absent one', () => {
|
|
117
|
+
expect(isIdsParamProvided('not-a-uuid')).toBe(true)
|
|
118
|
+
expect(isIdsParamProvided(`${idA}`)).toBe(true)
|
|
119
|
+
expect(isIdsParamProvided('')).toBe(false)
|
|
120
|
+
expect(isIdsParamProvided(' ')).toBe(false)
|
|
121
|
+
expect(isIdsParamProvided(undefined)).toBe(false)
|
|
122
|
+
expect(isIdsParamProvided(null)).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('mergeIdFilter matches nothing when ids param was provided but all invalid', () => {
|
|
126
|
+
// Malformed input: parseIdsParam yields [], but the param WAS provided.
|
|
127
|
+
expect(mergeIdFilter({}, parseIdsParam('not-a-uuid'), { idsParamProvided: true })).toEqual({
|
|
128
|
+
id: { $in: [] },
|
|
129
|
+
})
|
|
130
|
+
// Preserves any existing filters alongside the match-nothing id clause.
|
|
131
|
+
expect(
|
|
132
|
+
mergeIdFilter({ tenantId: 't1' } as any, [], { idsParamProvided: true }),
|
|
133
|
+
).toEqual({ tenantId: 't1', id: { $in: [] } })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('mergeIdFilter still no-ops on empty ids when the param was absent (BC)', () => {
|
|
137
|
+
expect(mergeIdFilter({ tenantId: 't1' } as any, [])).toEqual({ tenantId: 't1' })
|
|
138
|
+
expect(mergeIdFilter({ tenantId: 't1' } as any, [], { idsParamProvided: false })).toEqual({
|
|
139
|
+
tenantId: 't1',
|
|
140
|
+
})
|
|
141
|
+
})
|
|
112
142
|
})
|
package/src/lib/crud/cache.ts
CHANGED
|
@@ -191,11 +191,14 @@ export async function invalidateCrudCache(
|
|
|
191
191
|
if (recordId) {
|
|
192
192
|
tags.add(buildRecordTag(key, tenantId, recordId))
|
|
193
193
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
194
|
+
// Always flush the tenant-level (org:null) collection tag alongside the
|
|
195
|
+
// write's own org. Tenant-scoped entities (orgField: null — e.g. auth roles)
|
|
196
|
+
// cache their collections under org:null, but the command bus resolves a
|
|
197
|
+
// write's organizationId from the actor's auth context, so the org-specific
|
|
198
|
+
// tag alone never matches those entries and they only expire on TTL (#2919).
|
|
199
|
+
const organizationIds: Array<string | null> = [null]
|
|
200
|
+
const recordOrganizationId = identifiers.organizationId ?? null
|
|
201
|
+
if (recordOrganizationId) organizationIds.push(recordOrganizationId)
|
|
199
202
|
for (const tag of buildCollectionTags(key, tenantId, organizationIds)) {
|
|
200
203
|
tags.add(tag)
|
|
201
204
|
}
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -66,7 +66,7 @@ import { applyResponseEnrichers, applyResponseEnricherToRecord, resolveListCache
|
|
|
66
66
|
import type { EnricherContext } from './response-enricher'
|
|
67
67
|
import type { ApiInterceptorMethod, InterceptorRequest, InterceptorResponse } from './api-interceptor'
|
|
68
68
|
import { runApiInterceptorsAfter, runApiInterceptorsBefore } from './interceptor-runner'
|
|
69
|
-
import { mergeIdFilter, parseIdsParam } from './ids'
|
|
69
|
+
import { mergeIdFilter, parseIdsParam, isIdsParamProvided } from './ids'
|
|
70
70
|
import { mergeAdvancedFilters } from './advanced-filter-integration'
|
|
71
71
|
import { parseExtensionHeaders } from '../umes/extension-headers'
|
|
72
72
|
import { createGenericOptimisticLockReader } from './optimistic-lock'
|
|
@@ -1445,6 +1445,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1445
1445
|
...(interceptorRequest.query ?? {}),
|
|
1446
1446
|
} as Record<string, unknown>
|
|
1447
1447
|
const parsedIds = parseIdsParam(queryParams.ids)
|
|
1448
|
+
const idsParamProvided = isIdsParamProvided(queryParams.ids)
|
|
1448
1449
|
|
|
1449
1450
|
await opts.hooks?.beforeList?.(validated as any, ctx)
|
|
1450
1451
|
profiler.mark('before_list_hook')
|
|
@@ -1664,7 +1665,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1664
1665
|
const filters = exportFullRequested
|
|
1665
1666
|
? baseFilters
|
|
1666
1667
|
: mergeAdvancedFilters(baseFilters as Record<string, unknown>, validated as Record<string, unknown>) as Where<any>
|
|
1667
|
-
const mergedFilters = exportFullRequested ? filters : mergeIdFilter(filters, parsedIds)
|
|
1668
|
+
const mergedFilters = exportFullRequested ? filters : mergeIdFilter(filters, parsedIds, { idsParamProvided })
|
|
1668
1669
|
const withDeleted = parseBooleanToken((queryParams as any).withDeleted) === true
|
|
1669
1670
|
profiler.mark('filters_ready', { withDeleted })
|
|
1670
1671
|
if (
|
|
@@ -1951,7 +1952,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1951
1952
|
: mergeAdvancedFilters(fallbackBaseFilters as Record<string, unknown>, validated as Record<string, unknown>) as Where<any>
|
|
1952
1953
|
const mergedFallbackFilters = exportFullRequested
|
|
1953
1954
|
? fallbackFilters
|
|
1954
|
-
: mergeIdFilter(fallbackFilters, parsedIds)
|
|
1955
|
+
: mergeIdFilter(fallbackFilters, parsedIds, { idsParamProvided })
|
|
1955
1956
|
const ormFilters = translateFiltersForOrm(
|
|
1956
1957
|
mergedFallbackFilters as Record<string, any>,
|
|
1957
1958
|
em,
|
package/src/lib/crud/ids.ts
CHANGED
|
@@ -49,11 +49,32 @@ export function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST
|
|
|
49
49
|
return parsed.slice(0, safeMax)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Whether an `?ids=` param was supplied at all (a non-empty string), regardless
|
|
54
|
+
* of whether any value survived UUID validation. Lets a caller tell "no ids
|
|
55
|
+
* filter requested" apart from "ids filter requested but every value was
|
|
56
|
+
* malformed" — the latter must match nothing, not fall back to the full list
|
|
57
|
+
* (#4143 Finding 3).
|
|
58
|
+
*/
|
|
59
|
+
export function isIdsParamProvided(raw: unknown): boolean {
|
|
60
|
+
return typeof raw === 'string' && raw.trim().length > 0
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
export function mergeIdFilter<Fields extends Record<string, unknown>>(
|
|
53
64
|
existingFilters: Where<Fields>,
|
|
54
65
|
parsedIds: string[],
|
|
66
|
+
options?: { idsParamProvided?: boolean },
|
|
55
67
|
): Where<Fields> {
|
|
56
|
-
if (parsedIds.length === 0)
|
|
68
|
+
if (parsedIds.length === 0) {
|
|
69
|
+
// The `?ids=` param was supplied but nothing survived UUID validation.
|
|
70
|
+
// Match nothing, mirroring a valid-but-unknown id returning zero rows,
|
|
71
|
+
// rather than silently dropping the filter and returning the full list
|
|
72
|
+
// (record-count side channel — #4143 Finding 3).
|
|
73
|
+
if (options?.idsParamProvided) {
|
|
74
|
+
return { ...existingFilters, id: { $in: [] } }
|
|
75
|
+
}
|
|
76
|
+
return existingFilters
|
|
77
|
+
}
|
|
57
78
|
|
|
58
79
|
const existingFilter = (existingFilters as Record<string, unknown>).id
|
|
59
80
|
const existingIds = readExistingIds(existingFilter)
|