@furystack/core 15.0.36 → 15.2.0

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.
@@ -0,0 +1,148 @@
1
+ import type { FilterType } from './models/physical-store.js'
2
+ import { isLogicalOperator, isOperator } from './models/physical-store.js'
3
+
4
+ type FieldOperatorFilter = {
5
+ $eq?: unknown
6
+ $ne?: unknown
7
+ $in?: unknown[]
8
+ $nin?: unknown[]
9
+ $gt?: unknown
10
+ $gte?: unknown
11
+ $lt?: unknown
12
+ $lte?: unknown
13
+ $startsWith?: string
14
+ $endsWith?: string
15
+ $like?: string
16
+ $regex?: string
17
+ }
18
+
19
+ const escapeRegexMeta = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
20
+
21
+ const evaluateLike = (value: string, likeString: string) => {
22
+ const likeRegex = `^${likeString.split('%').map(escapeRegexMeta).join('.*')}$`
23
+ return value.match(new RegExp(likeRegex, 'i'))
24
+ }
25
+
26
+ /**
27
+ * Filters an array of items using a {@link FilterType} expression.
28
+ * Supports field-level operators ($eq, $ne, $in, $nin, $gt, $gte, $lt, $lte, $startsWith, $endsWith, $like, $regex)
29
+ * and logical operators ($and, $or).
30
+ * @param values The array of items to filter
31
+ * @param filter The filter expression to apply
32
+ * @returns The filtered array of items
33
+ */
34
+ export function filterItems<T>(values: T[], filter?: FilterType<T>): T[] {
35
+ if (!filter) {
36
+ return values
37
+ }
38
+ return values.filter((item) => {
39
+ const filterRecord = filter as Record<string, Array<FilterType<T>> | FieldOperatorFilter | undefined>
40
+ const itemRecord = item as Record<string, unknown>
41
+
42
+ for (const key in filterRecord) {
43
+ if (isLogicalOperator(key)) {
44
+ const filterValue = filterRecord[key] as Array<FilterType<T>>
45
+ switch (key) {
46
+ case '$and':
47
+ if (filterValue.some((v: FilterType<T>) => !filterItems([item], v).length)) {
48
+ return false
49
+ }
50
+ break
51
+ case '$or':
52
+ if (filterValue.some((v: FilterType<T>) => filterItems([item], v).length)) {
53
+ break
54
+ }
55
+ return false
56
+ default:
57
+ throw new Error(`The logical operation '${key}' is not a valid operation`)
58
+ }
59
+ } else {
60
+ const fieldFilter = filterRecord[key] as FieldOperatorFilter | undefined
61
+ if (typeof fieldFilter === 'object' && fieldFilter !== null) {
62
+ for (const filterKey in fieldFilter) {
63
+ if (isOperator(filterKey)) {
64
+ const itemValue = itemRecord[key]
65
+ const filterValue = fieldFilter[filterKey as keyof FieldOperatorFilter]
66
+ switch (filterKey) {
67
+ case '$eq':
68
+ if (filterValue !== itemValue) {
69
+ return false
70
+ }
71
+ break
72
+ case '$ne':
73
+ if (filterValue === itemValue) {
74
+ return false
75
+ }
76
+ break
77
+ case '$in':
78
+ if (!(filterValue as unknown[]).includes(itemValue)) {
79
+ return false
80
+ }
81
+ break
82
+ case '$nin':
83
+ if ((filterValue as unknown[]).includes(itemValue)) {
84
+ return false
85
+ }
86
+ break
87
+ case '$lt':
88
+ if ((itemValue as number) < (filterValue as number)) {
89
+ break
90
+ }
91
+ return false
92
+ case '$lte':
93
+ if ((itemValue as number) <= (filterValue as number)) {
94
+ break
95
+ }
96
+ return false
97
+ case '$gt':
98
+ if ((itemValue as number) > (filterValue as number)) {
99
+ break
100
+ }
101
+ return false
102
+ case '$gte':
103
+ if ((itemValue as number) >= (filterValue as number)) {
104
+ break
105
+ }
106
+ return false
107
+ case '$regex':
108
+ try {
109
+ if (!new RegExp(filterValue as string).test(String(itemValue))) {
110
+ return false
111
+ }
112
+ } catch (e) {
113
+ throw new Error(
114
+ `Invalid regular expression for $regex filter on field '${key}': ${(e as Error).message}`,
115
+ { cause: e },
116
+ )
117
+ }
118
+ break
119
+ case '$startsWith':
120
+ if (!(itemValue as string).startsWith(filterValue as string)) {
121
+ return false
122
+ }
123
+ break
124
+ case '$endsWith':
125
+ if (!(itemValue as string).endsWith(filterValue as string)) {
126
+ return false
127
+ }
128
+ break
129
+ case '$like':
130
+ if (!evaluateLike(itemValue as string, filterValue as string)) {
131
+ return false
132
+ }
133
+ break
134
+ default:
135
+ throw new Error(`The expression (${filterKey}) is not a supported filter operation`)
136
+ }
137
+ } else {
138
+ throw new Error(`The filter key '${filterKey}' is not a valid operation`)
139
+ }
140
+ }
141
+ } else {
142
+ throw new Error(`The filter has to be an object, got ${typeof fieldFilter} for field '${key}'`)
143
+ }
144
+ }
145
+ }
146
+ return true
147
+ })
148
+ }
@@ -1,25 +1,8 @@
1
1
  import type { Constructable } from '@furystack/inject'
2
2
  import { EventHub } from '@furystack/utils'
3
3
  import type { CreateResult, FilterType, FindOptions, PartialResult, PhysicalStore } from './models/physical-store.js'
4
- import { isLogicalOperator, isOperator, selectFields } from './models/physical-store.js'
5
-
6
- /**
7
- * Helper type representing all possible field filter operations
8
- */
9
- type FieldOperatorFilter = {
10
- $eq?: unknown
11
- $ne?: unknown
12
- $in?: unknown[]
13
- $nin?: unknown[]
14
- $gt?: unknown
15
- $gte?: unknown
16
- $lt?: unknown
17
- $lte?: unknown
18
- $startsWith?: string
19
- $endsWith?: string
20
- $like?: string
21
- $regex?: string
22
- }
4
+ import { selectFields } from './models/physical-store.js'
5
+ import { filterItems } from './filter-items.js'
23
6
 
24
7
  export class InMemoryStore<T, TPrimaryKey extends keyof T>
25
8
  extends EventHub<{
@@ -59,123 +42,8 @@ export class InMemoryStore<T, TPrimaryKey extends keyof T>
59
42
  return Promise.resolve(item && select ? selectFields(item, ...select) : item)
60
43
  }
61
44
 
62
- private evaluateLike = (value: string, likeString: string) => {
63
- const likeRegex = `^${likeString.replace(/%/g, '.*')}$`
64
- return value.match(new RegExp(likeRegex, 'i'))
65
- }
66
-
67
- private filterInternal(values: T[], filter?: FilterType<T>): T[] {
68
- if (!filter) {
69
- return values
70
- }
71
- return values.filter((item) => {
72
- const filterRecord = filter as Record<string, Array<FilterType<T>> | FieldOperatorFilter | undefined>
73
- const itemRecord = item as Record<string, unknown>
74
-
75
- for (const key in filterRecord) {
76
- if (isLogicalOperator(key)) {
77
- const filterValue = filterRecord[key] as Array<FilterType<T>>
78
- switch (key) {
79
- case '$and':
80
- if (filterValue.some((v: FilterType<T>) => !this.filterInternal([item], v).length)) {
81
- return false
82
- }
83
- break
84
- case '$or':
85
- if (filterValue.some((v: FilterType<T>) => this.filterInternal([item], v).length)) {
86
- break
87
- }
88
- return false
89
- default:
90
- throw new Error(`The logical operation '${key}' is not a valid operation`)
91
- }
92
- } else {
93
- const fieldFilter = filterRecord[key] as FieldOperatorFilter | undefined
94
- if (typeof fieldFilter === 'object' && fieldFilter !== null) {
95
- for (const filterKey in fieldFilter) {
96
- if (isOperator(filterKey)) {
97
- const itemValue = itemRecord[key]
98
- const filterValue = fieldFilter[filterKey as keyof FieldOperatorFilter]
99
- switch (filterKey) {
100
- case '$eq':
101
- if (filterValue !== itemValue) {
102
- return false
103
- }
104
- break
105
- case '$ne':
106
- if (filterValue === itemValue) {
107
- return false
108
- }
109
- break
110
- case '$in':
111
- if (!(filterValue as unknown[]).includes(itemValue)) {
112
- return false
113
- }
114
- break
115
-
116
- case '$nin':
117
- if ((filterValue as unknown[]).includes(itemValue)) {
118
- return false
119
- }
120
- break
121
- case '$lt':
122
- if ((itemValue as number) < (filterValue as number)) {
123
- break
124
- }
125
- return false
126
- case '$lte':
127
- if ((itemValue as number) <= (filterValue as number)) {
128
- break
129
- }
130
- return false
131
- case '$gt':
132
- if ((itemValue as number) > (filterValue as number)) {
133
- break
134
- }
135
- return false
136
- case '$gte':
137
- if ((itemValue as number) >= (filterValue as number)) {
138
- break
139
- }
140
- return false
141
- case '$regex':
142
- if (!new RegExp(filterValue as string).test(String(itemValue))) {
143
- return false
144
- }
145
- break
146
- case '$startsWith':
147
- if (!(itemValue as string).startsWith(filterValue as string)) {
148
- return false
149
- }
150
- break
151
- case '$endsWith':
152
- if (!(itemValue as string).endsWith(filterValue as string)) {
153
- return false
154
- }
155
- break
156
- case '$like':
157
- if (!this.evaluateLike(itemValue as string, filterValue as string)) {
158
- return false
159
- }
160
- break
161
- default:
162
- throw new Error(`The expression (${filterKey}) is not supported by '${this.constructor.name}'!`)
163
- }
164
- } else {
165
- throw new Error(`The filter key '${filterKey}' is not a valid operation`)
166
- }
167
- }
168
- } else {
169
- throw new Error(`The filter has to be an object, got ${typeof fieldFilter} for field '${key}'`)
170
- }
171
- }
172
- }
173
- return true
174
- })
175
- }
176
-
177
45
  public async find<TFields extends Array<keyof T>>(searchOptions: FindOptions<T, TFields>) {
178
- let value: Array<PartialResult<T, TFields>> = this.filterInternal([...this.cache.values()], searchOptions.filter)
46
+ let value: Array<PartialResult<T, TFields>> = filterItems([...this.cache.values()], searchOptions.filter)
179
47
 
180
48
  if (searchOptions.order) {
181
49
  const orderRecord = searchOptions.order as Record<string, 'ASC' | 'DESC'>
@@ -203,7 +71,7 @@ export class InMemoryStore<T, TPrimaryKey extends keyof T>
203
71
  }
204
72
 
205
73
  public async count(filter?: FilterType<T>) {
206
- return this.filterInternal([...this.cache.values()], filter).length
74
+ return filterItems([...this.cache.values()], filter).length
207
75
  }
208
76
 
209
77
  public async update(id: T[TPrimaryKey], data: T) {
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export * from './errors/index.js'
2
2
  export * from './models/physical-store.js'
3
3
  export * from './models/user.js'
4
+ export * from './filter-items.js'
4
5
  export * from './in-memory-store.js'
5
6
  export * from './store-manager.js'
6
7
  export * from './global-disposables.js'
7
8
  export * from './identity-context.js'
9
+ export * from './system-identity-context.js'
8
10
  export * from './helpers.js'
@@ -88,7 +88,14 @@ export const selectFields = <T extends object, TField extends Array<keyof T>>(en
88
88
  }
89
89
 
90
90
  /**
91
- * Interface that defines a physical store implementation
91
+ * Interface that defines a physical store implementation.
92
+ *
93
+ * **Important:** Writing directly to a physical store bypasses the Repository {@link DataSet} layer.
94
+ * This means authorization, modification hooks, and DataSet events (used by entity sync) will **not** be triggered.
95
+ * For any write operation that should be observable by other parts of the system (e.g. entity sync, audit logging),
96
+ * use the corresponding {@link DataSet} method instead via `getDataSetFor()`.
97
+ *
98
+ * @see {@link DataSet} for the authorized, event-emitting write gateway
92
99
  */
93
100
  export interface PhysicalStore<
94
101
  T,
@@ -0,0 +1,101 @@
1
+ import { Injector } from '@furystack/inject'
2
+ import { usingAsync } from '@furystack/utils'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { getCurrentUser, isAuthenticated, isAuthorized } from './helpers.js'
5
+ import { IdentityContext } from './identity-context.js'
6
+ import { SystemIdentityContext, useSystemIdentityContext } from './system-identity-context.js'
7
+
8
+ describe('SystemIdentityContext', () => {
9
+ it('isAuthenticated should return true', async () => {
10
+ const ctx = new SystemIdentityContext()
11
+ expect(await ctx.isAuthenticated()).toBe(true)
12
+ })
13
+
14
+ it('isAuthorized should return true without roles', async () => {
15
+ const ctx = new SystemIdentityContext()
16
+ expect(await ctx.isAuthorized()).toBe(true)
17
+ })
18
+
19
+ it('isAuthorized should return true with roles', async () => {
20
+ const ctx = new SystemIdentityContext()
21
+ expect(await ctx.isAuthorized('admin', 'superuser')).toBe(true)
22
+ })
23
+
24
+ it('getCurrentUser should return default system user', async () => {
25
+ const ctx = new SystemIdentityContext()
26
+ const user = await ctx.getCurrentUser()
27
+ expect(user).toEqual({ username: 'system', roles: [] })
28
+ })
29
+
30
+ it('getCurrentUser should respect custom username', async () => {
31
+ const ctx = new SystemIdentityContext({ username: 'migration-job' })
32
+ const user = await ctx.getCurrentUser()
33
+ expect(user).toEqual({ username: 'migration-job', roles: [] })
34
+ })
35
+ })
36
+
37
+ describe('useSystemIdentityContext', () => {
38
+ it('should return a child injector, not the parent', async () => {
39
+ await usingAsync(new Injector(), async (parent) => {
40
+ const child = useSystemIdentityContext({ injector: parent })
41
+ expect(child).not.toBe(parent)
42
+ await child[Symbol.asyncDispose]()
43
+ })
44
+ })
45
+
46
+ it('should resolve IdentityContext to a SystemIdentityContext', async () => {
47
+ await usingAsync(new Injector(), async (parent) => {
48
+ await usingAsync(useSystemIdentityContext({ injector: parent }), async (child) => {
49
+ const ctx = child.getInstance(IdentityContext)
50
+ expect(ctx).toBeInstanceOf(SystemIdentityContext)
51
+ })
52
+ })
53
+ })
54
+
55
+ it('should be authenticated and authorized via helpers', async () => {
56
+ await usingAsync(new Injector(), async (parent) => {
57
+ await usingAsync(useSystemIdentityContext({ injector: parent }), async (child) => {
58
+ expect(await isAuthenticated(child)).toBe(true)
59
+ expect(await isAuthorized(child, 'admin')).toBe(true)
60
+ })
61
+ })
62
+ })
63
+
64
+ it('should return the configured username via getCurrentUser', async () => {
65
+ await usingAsync(new Injector(), async (parent) => {
66
+ await usingAsync(useSystemIdentityContext({ injector: parent, username: 'seed-script' }), async (child) => {
67
+ const user = await getCurrentUser(child)
68
+ expect(user).toEqual({ username: 'seed-script', roles: [] })
69
+ })
70
+ })
71
+ })
72
+
73
+ it('should use default username when not specified', async () => {
74
+ await usingAsync(new Injector(), async (parent) => {
75
+ await usingAsync(useSystemIdentityContext({ injector: parent }), async (child) => {
76
+ const user = await getCurrentUser(child)
77
+ expect(user.username).toBe('system')
78
+ })
79
+ })
80
+ })
81
+
82
+ it('should dispose the child injector after usingAsync completes', async () => {
83
+ await usingAsync(new Injector(), async (parent) => {
84
+ let childRef: Injector | undefined
85
+ await usingAsync(useSystemIdentityContext({ injector: parent }), async (child) => {
86
+ childRef = child
87
+ })
88
+ expect(childRef).toBeDefined()
89
+ expect(() => childRef!.getInstance(IdentityContext)).toThrow('Injector already disposed')
90
+ })
91
+ })
92
+
93
+ it('should not dispose the parent injector', async () => {
94
+ await usingAsync(new Injector(), async (parent) => {
95
+ await usingAsync(useSystemIdentityContext({ injector: parent }), async () => {
96
+ // no-op
97
+ })
98
+ expect(() => parent.getInstance(IdentityContext)).not.toThrow()
99
+ })
100
+ })
101
+ })
@@ -0,0 +1,83 @@
1
+ import type { Injector } from '@furystack/inject'
2
+
3
+ import { IdentityContext } from './identity-context.js'
4
+ import type { User } from './models/user.js'
5
+
6
+ /**
7
+ * An elevated {@link IdentityContext} that is always authenticated and authorized.
8
+ * Intended for trusted server-side operations such as background jobs, migrations, and seed scripts.
9
+ *
10
+ * **Warning:** This context bypasses **all** authorization checks. Never use it in user-facing
11
+ * request pipelines or any context where untrusted input could reach the DataSet.
12
+ * Prefer {@link useSystemIdentityContext} for scoped usage with automatic cleanup.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { useSystemIdentityContext } from '@furystack/core'
17
+ * import { getDataSetFor } from '@furystack/repository'
18
+ * import { usingAsync } from '@furystack/utils'
19
+ *
20
+ * await usingAsync(
21
+ * useSystemIdentityContext({ injector, username: 'migration-job' }),
22
+ * async (systemInjector) => {
23
+ * const dataSet = getDataSetFor(systemInjector, MyModel, 'id')
24
+ * await dataSet.add(systemInjector, newEntity)
25
+ * },
26
+ * )
27
+ * ```
28
+ */
29
+ export class SystemIdentityContext extends IdentityContext {
30
+ private readonly username: string
31
+
32
+ constructor(options?: { username?: string }) {
33
+ super()
34
+ this.username = options?.username ?? 'system'
35
+ }
36
+
37
+ public override isAuthenticated() {
38
+ return Promise.resolve(true)
39
+ }
40
+
41
+ public override isAuthorized(..._roles: string[]) {
42
+ return Promise.resolve(true)
43
+ }
44
+
45
+ public override getCurrentUser<TUser extends User>(): Promise<TUser> {
46
+ return Promise.resolve({ username: this.username, roles: [] } as unknown as TUser)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Creates a scoped child injector with an elevated {@link SystemIdentityContext}.
52
+ * The returned injector is {@link AsyncDisposable} and works with `usingAsync()` for automatic cleanup.
53
+ *
54
+ * **Warning:** The returned injector bypasses **all** authorization checks. Only use this in trusted
55
+ * server-side contexts (background jobs, migrations, seed scripts). Never pass the returned injector
56
+ * to user-facing request handlers.
57
+ *
58
+ * @param options.injector The parent injector to create a child from
59
+ * @param options.username The username for the system identity (defaults to `'system'`)
60
+ * @returns A child injector with the SystemIdentityContext set as the IdentityContext
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * import { useSystemIdentityContext } from '@furystack/core'
65
+ * import { getDataSetFor } from '@furystack/repository'
66
+ * import { usingAsync } from '@furystack/utils'
67
+ *
68
+ * await usingAsync(
69
+ * useSystemIdentityContext({ injector, username: 'seed-script' }),
70
+ * async (systemInjector) => {
71
+ * const dataSet = getDataSetFor(systemInjector, MyModel, 'id')
72
+ * await dataSet.add(systemInjector, { value: 'seeded' })
73
+ * },
74
+ * )
75
+ * // systemInjector is disposed here -- all scoped instances cleaned up
76
+ * ```
77
+ */
78
+ export const useSystemIdentityContext = (options: { injector: Injector; username?: string }): Injector => {
79
+ const ctx = new SystemIdentityContext({ username: options.username })
80
+ const childInjector = options.injector.createChild({ owner: 'SystemIdentityContext' })
81
+ childInjector.setExplicitInstance(ctx, IdentityContext)
82
+ return childInjector
83
+ }