@enspirit/bmg-js 1.0.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.
Files changed (90) hide show
  1. package/.claude/safe-setup/.env.example +3 -0
  2. package/.claude/safe-setup/Dockerfile.claude +36 -0
  3. package/.claude/safe-setup/HACKING.md +63 -0
  4. package/.claude/safe-setup/Makefile +22 -0
  5. package/.claude/safe-setup/docker-compose.yml +18 -0
  6. package/.claude/safe-setup/entrypoint.sh +13 -0
  7. package/.claude/settings.local.json +9 -0
  8. package/.claude/typescript-annotations.md +273 -0
  9. package/.github/workflows/test.yml +26 -0
  10. package/CLAUDE.md +48 -0
  11. package/Makefile +2 -0
  12. package/README.md +170 -0
  13. package/example/README.md +22 -0
  14. package/example/index.ts +316 -0
  15. package/example/package.json +16 -0
  16. package/example/tsconfig.json +11 -0
  17. package/package.json +34 -0
  18. package/src/Relation/Memory.ts +213 -0
  19. package/src/Relation/index.ts +1 -0
  20. package/src/index.ts +31 -0
  21. package/src/operators/_helpers.ts +240 -0
  22. package/src/operators/allbut.ts +19 -0
  23. package/src/operators/autowrap.ts +26 -0
  24. package/src/operators/constants.ts +12 -0
  25. package/src/operators/cross_product.ts +20 -0
  26. package/src/operators/exclude.ts +14 -0
  27. package/src/operators/extend.ts +20 -0
  28. package/src/operators/group.ts +53 -0
  29. package/src/operators/image.ts +27 -0
  30. package/src/operators/index.ts +31 -0
  31. package/src/operators/intersect.ts +24 -0
  32. package/src/operators/isEqual.ts +29 -0
  33. package/src/operators/isRelation.ts +5 -0
  34. package/src/operators/join.ts +25 -0
  35. package/src/operators/left_join.ts +41 -0
  36. package/src/operators/matching.ts +24 -0
  37. package/src/operators/minus.ts +24 -0
  38. package/src/operators/not_matching.ts +24 -0
  39. package/src/operators/one.ts +17 -0
  40. package/src/operators/prefix.ts +7 -0
  41. package/src/operators/project.ts +18 -0
  42. package/src/operators/rename.ts +17 -0
  43. package/src/operators/restrict.ts +14 -0
  44. package/src/operators/suffix.ts +7 -0
  45. package/src/operators/summarize.ts +85 -0
  46. package/src/operators/transform.ts +40 -0
  47. package/src/operators/ungroup.ts +41 -0
  48. package/src/operators/union.ts +27 -0
  49. package/src/operators/unwrap.ts +29 -0
  50. package/src/operators/where.ts +1 -0
  51. package/src/operators/wrap.ts +29 -0
  52. package/src/operators/yByX.ts +12 -0
  53. package/src/support/toPredicateFunc.ts +12 -0
  54. package/src/types.ts +178 -0
  55. package/src/utility-types.ts +77 -0
  56. package/tests/bmg.test.ts +16 -0
  57. package/tests/fixtures.ts +9 -0
  58. package/tests/operators/allbut.test.ts +51 -0
  59. package/tests/operators/autowrap.test.ts +82 -0
  60. package/tests/operators/constants.test.ts +37 -0
  61. package/tests/operators/cross_product.test.ts +90 -0
  62. package/tests/operators/exclude.test.ts +43 -0
  63. package/tests/operators/extend.test.ts +45 -0
  64. package/tests/operators/group.test.ts +69 -0
  65. package/tests/operators/image.test.ts +152 -0
  66. package/tests/operators/intersect.test.ts +53 -0
  67. package/tests/operators/isEqual.test.ts +111 -0
  68. package/tests/operators/join.test.ts +116 -0
  69. package/tests/operators/left_join.test.ts +116 -0
  70. package/tests/operators/matching.test.ts +91 -0
  71. package/tests/operators/minus.test.ts +47 -0
  72. package/tests/operators/not_matching.test.ts +104 -0
  73. package/tests/operators/one.test.ts +19 -0
  74. package/tests/operators/prefix.test.ts +37 -0
  75. package/tests/operators/project.test.ts +48 -0
  76. package/tests/operators/rename.test.ts +39 -0
  77. package/tests/operators/restrict.test.ts +27 -0
  78. package/tests/operators/suffix.test.ts +37 -0
  79. package/tests/operators/summarize.test.ts +109 -0
  80. package/tests/operators/transform.test.ts +94 -0
  81. package/tests/operators/ungroup.test.ts +67 -0
  82. package/tests/operators/union.test.ts +51 -0
  83. package/tests/operators/unwrap.test.ts +50 -0
  84. package/tests/operators/where.test.ts +33 -0
  85. package/tests/operators/wrap.test.ts +54 -0
  86. package/tests/operators/yByX.test.ts +32 -0
  87. package/tests/types/relation.test.ts +296 -0
  88. package/tsconfig.json +37 -0
  89. package/tsconfig.node.json +9 -0
  90. package/vitest.config.ts +15 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Type-level tests for the generic Relation<T> system.
3
+ * These tests verify compile-time type safety.
4
+ */
5
+ import { describe, it, expectTypeOf } from 'vitest';
6
+ import { Bmg } from 'src';
7
+ import type { Relation, Tuple } from 'src/types';
8
+
9
+ // Define a typed interface for testing
10
+ interface Person {
11
+ id: number;
12
+ name: string;
13
+ age: number;
14
+ city: string;
15
+ }
16
+
17
+ interface Order {
18
+ orderId: number;
19
+ customerId: number;
20
+ total: number;
21
+ }
22
+
23
+ describe('Type Safety', () => {
24
+
25
+ describe('Bmg factory', () => {
26
+ it('infers type from input array', () => {
27
+ const r = Bmg([{ id: 1, name: 'Alice' }]);
28
+ expectTypeOf(r.one()).toMatchTypeOf<{ id: number; name: string }>();
29
+ });
30
+
31
+ it('accepts explicit type parameter', () => {
32
+ const r = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
33
+ expectTypeOf(r).toMatchTypeOf<Relation<Person>>();
34
+ });
35
+
36
+ it('returns untyped Relation when no type info', () => {
37
+ const data: Tuple[] = [{ a: 1 }];
38
+ const r = Bmg(data);
39
+ expectTypeOf(r).toMatchTypeOf<Relation<Tuple>>();
40
+ });
41
+ });
42
+
43
+ describe('one() and toArray()', () => {
44
+ it('returns typed tuple from one()', () => {
45
+ const r = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
46
+ const tuple = r.one();
47
+ expectTypeOf(tuple.id).toBeNumber();
48
+ expectTypeOf(tuple.name).toBeString();
49
+ expectTypeOf(tuple.age).toBeNumber();
50
+ });
51
+
52
+ it('returns typed array from toArray()', () => {
53
+ const r = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
54
+ const arr = r.toArray();
55
+ expectTypeOf(arr).toMatchTypeOf<Person[]>();
56
+ });
57
+ });
58
+
59
+ describe('project()', () => {
60
+ it('narrows type to selected attributes', () => {
61
+ const r = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
62
+ const projected = r.project(['id', 'name']);
63
+ expectTypeOf(projected.one()).toMatchTypeOf<{ id: number; name: string }>();
64
+ });
65
+
66
+ it('accepts valid attribute names only', () => {
67
+ const r = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
68
+ // This should compile - valid attributes
69
+ r.project(['id', 'name']);
70
+
71
+ // @ts-expect-error - 'invalid' is not a valid attribute
72
+ r.project(['invalid']);
73
+ });
74
+ });
75
+
76
+ describe('allbut()', () => {
77
+ it('removes specified attributes from type', () => {
78
+ const r = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
79
+ const result = r.allbut(['age', 'city']);
80
+ expectTypeOf(result.one()).toMatchTypeOf<{ id: number; name: string }>();
81
+ });
82
+ });
83
+
84
+ describe('restrict/where/exclude', () => {
85
+ it('preserves type through restrict', () => {
86
+ const r = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
87
+ const filtered = r.restrict({ city: 'NYC' });
88
+ expectTypeOf(filtered.one()).toMatchTypeOf<Person>();
89
+ });
90
+
91
+ it('provides typed tuple in predicate function', () => {
92
+ const r = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
93
+ r.restrict(t => {
94
+ expectTypeOf(t.age).toBeNumber();
95
+ return t.age > 25;
96
+ });
97
+ });
98
+
99
+ it('where is alias for restrict', () => {
100
+ const r = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
101
+ const filtered = r.where(t => t.age > 25);
102
+ expectTypeOf(filtered).toMatchTypeOf<Relation<Person>>();
103
+ });
104
+ });
105
+
106
+ describe('rename()', () => {
107
+ it('transforms attribute names in type', () => {
108
+ const r = Bmg<{ name: string; age: number }>([{ name: 'Alice', age: 30 }]);
109
+ const renamed = r.rename({ name: 'fullName' });
110
+ const tuple = renamed.one();
111
+ // Check individual properties since mapped types can be tricky for toMatchTypeOf
112
+ expectTypeOf(tuple.fullName).toBeString();
113
+ expectTypeOf(tuple.age).toBeNumber();
114
+ });
115
+ });
116
+
117
+ describe('extend()', () => {
118
+ it('adds new attributes to type', () => {
119
+ const r = Bmg<{ id: number; age: number }>([{ id: 1, age: 30 }]);
120
+ const extended = r.extend({
121
+ senior: (t) => t.age >= 65,
122
+ label: (t) => `Person ${t.id}`
123
+ });
124
+ const tuple = extended.one();
125
+ expectTypeOf(tuple.id).toBeNumber();
126
+ expectTypeOf(tuple.age).toBeNumber();
127
+ expectTypeOf(tuple.senior).toBeBoolean();
128
+ expectTypeOf(tuple.label).toBeString();
129
+ });
130
+ });
131
+
132
+ describe('constants()', () => {
133
+ it('adds constant attributes to type', () => {
134
+ const r = Bmg<{ id: number }>([{ id: 1 }]);
135
+ const result = r.constants({ version: 1, active: true });
136
+ const tuple = result.one();
137
+ expectTypeOf(tuple.id).toBeNumber();
138
+ expectTypeOf(tuple.version).toBeNumber();
139
+ expectTypeOf(tuple.active).toBeBoolean();
140
+ });
141
+ });
142
+
143
+ describe('join()', () => {
144
+ it('combines types from both relations', () => {
145
+ const people = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
146
+ // Use array to avoid variance issues with Relation<Order> vs Relation<Tuple>
147
+ const orders: Order[] = [{ orderId: 100, customerId: 1, total: 50 }];
148
+
149
+ const joined = people.join(orders, { id: 'customerId' });
150
+ const tuple = joined.one();
151
+
152
+ // Should have all Person attributes
153
+ expectTypeOf(tuple.id).toBeNumber();
154
+ expectTypeOf(tuple.name).toBeString();
155
+ expectTypeOf(tuple.age).toBeNumber();
156
+
157
+ // Should have Order attributes (minus the join key)
158
+ expectTypeOf(tuple.orderId).toBeNumber();
159
+ expectTypeOf(tuple.total).toBeNumber();
160
+ });
161
+ });
162
+
163
+ describe('cross_product()', () => {
164
+ it('combines all attributes from both relations', () => {
165
+ const r1 = Bmg<{ a: number }>([{ a: 1 }]);
166
+ const r2 = Bmg<{ b: string }>([{ b: 'x' }]);
167
+
168
+ const product = r1.cross_product(r2);
169
+ const tuple = product.one();
170
+
171
+ expectTypeOf(tuple.a).toBeNumber();
172
+ expectTypeOf(tuple.b).toBeString();
173
+ });
174
+ });
175
+
176
+ describe('union/minus/intersect', () => {
177
+ it('preserves type in set operations', () => {
178
+ const r1 = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
179
+ const r2 = Bmg<Person>([{ id: 2, name: 'Bob', age: 25, city: 'LA' }]);
180
+
181
+ expectTypeOf(r1.union(r2)).toMatchTypeOf<Relation<Person>>();
182
+ expectTypeOf(r1.minus(r2)).toMatchTypeOf<Relation<Person>>();
183
+ expectTypeOf(r1.intersect(r2)).toMatchTypeOf<Relation<Person>>();
184
+ });
185
+ });
186
+
187
+ describe('matching/not_matching', () => {
188
+ it('preserves left type in semi-joins', () => {
189
+ const people = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
190
+ // Use array to avoid variance issues with Relation<Order> vs Relation<Tuple>
191
+ const orders: Order[] = [{ orderId: 100, customerId: 1, total: 50 }];
192
+
193
+ const matched = people.matching(orders, { id: 'customerId' });
194
+ expectTypeOf(matched).toMatchTypeOf<Relation<Person>>();
195
+
196
+ const notMatched = people.not_matching(orders, { id: 'customerId' });
197
+ expectTypeOf(notMatched).toMatchTypeOf<Relation<Person>>();
198
+ });
199
+ });
200
+
201
+ describe('group()', () => {
202
+ it('creates nested relation type', () => {
203
+ const r = Bmg<{ orderId: number; item: string; qty: number }>([
204
+ { orderId: 1, item: 'A', qty: 10 }
205
+ ]);
206
+
207
+ const grouped = r.group(['item', 'qty'], 'items');
208
+ const tuple = grouped.one();
209
+
210
+ expectTypeOf(tuple.orderId).toBeNumber();
211
+ // items should be a Relation of the grouped attributes
212
+ expectTypeOf(tuple.items).toMatchTypeOf<Relation<{ item: string; qty: number }>>();
213
+ });
214
+ });
215
+
216
+ describe('wrap()', () => {
217
+ it('creates nested object type', () => {
218
+ const r = Bmg<{ id: number; street: string; city: string }>([
219
+ { id: 1, street: '123 Main', city: 'NYC' }
220
+ ]);
221
+
222
+ const wrapped = r.wrap(['street', 'city'], 'address');
223
+ const tuple = wrapped.one();
224
+
225
+ expectTypeOf(tuple.id).toBeNumber();
226
+ expectTypeOf(tuple.address).toMatchTypeOf<{ street: string; city: string }>();
227
+ });
228
+ });
229
+
230
+ describe('prefix/suffix', () => {
231
+ it('transforms attribute names with prefix', () => {
232
+ const r = Bmg<{ id: number; name: string }>([{ id: 1, name: 'Alice' }]);
233
+ const prefixed = r.prefix('user_');
234
+ const tuple = prefixed.one();
235
+
236
+ expectTypeOf(tuple).toMatchTypeOf<{ user_id: number; user_name: string }>();
237
+ });
238
+
239
+ it('transforms attribute names with suffix', () => {
240
+ const r = Bmg<{ id: number; name: string }>([{ id: 1, name: 'Alice' }]);
241
+ const suffixed = r.suffix('_val');
242
+ const tuple = suffixed.one();
243
+
244
+ expectTypeOf(tuple).toMatchTypeOf<{ id_val: number; name_val: string }>();
245
+ });
246
+ });
247
+
248
+ describe('yByX()', () => {
249
+ it('returns correctly typed record', () => {
250
+ const r = Bmg<{ id: number; name: string }>([
251
+ { id: 1, name: 'Alice' },
252
+ { id: 2, name: 'Bob' }
253
+ ]);
254
+
255
+ const byId = r.yByX('name', 'id');
256
+ expectTypeOf(byId).toMatchTypeOf<Record<number, string>>();
257
+ });
258
+ });
259
+
260
+ describe('transform()', () => {
261
+ it('preserves type structure', () => {
262
+ const r = Bmg<{ id: number; name: string }>([{ id: 1, name: 'Alice' }]);
263
+ const transformed = r.transform({ name: v => (v as string).toUpperCase() });
264
+ expectTypeOf(transformed).toMatchTypeOf<Relation<{ id: number; name: string }>>();
265
+ });
266
+ });
267
+
268
+ describe('autowrap()', () => {
269
+ it('returns untyped Relation (dynamic)', () => {
270
+ const r = Bmg<{ id: number; user_name: string; user_email: string }>([
271
+ { id: 1, user_name: 'Alice', user_email: 'alice@test.com' }
272
+ ]);
273
+
274
+ const autowrapped = r.autowrap();
275
+ // autowrap loses type information (dynamic structure)
276
+ expectTypeOf(autowrapped).toMatchTypeOf<Relation<Tuple>>();
277
+ });
278
+ });
279
+
280
+ describe('method chaining', () => {
281
+ it('preserves types through operator chain', () => {
282
+ const r = Bmg<Person>([{ id: 1, name: 'Alice', age: 30, city: 'NYC' }]);
283
+
284
+ const result = r
285
+ .restrict(t => t.age > 25)
286
+ .project(['id', 'name'])
287
+ .rename({ name: 'fullName' });
288
+
289
+ const tuple = result.one();
290
+ // Check individual properties since mapped types can be tricky for toMatchTypeOf
291
+ expectTypeOf(tuple.id).toBeNumber();
292
+ expectTypeOf(tuple.fullName).toBeString();
293
+ });
294
+ });
295
+
296
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "baseUrl": "./",
6
+ "outDir": "dist",
7
+ "lib": ["esnext", "dom", "dom.iterable"],
8
+
9
+ "useDefineForClassFields": true,
10
+
11
+ "skipLibCheck": true,
12
+ "allowJs": true,
13
+
14
+ /* Bundler mode */
15
+ "moduleResolution": "node",
16
+ "resolveJsonModule": true,
17
+ "isolatedModules": true,
18
+ "noEmit": true,
19
+ "jsx": "preserve",
20
+
21
+ /* Linting */
22
+ "strict": true,
23
+ "noUnusedLocals": true,
24
+ "noUnusedParameters": true,
25
+ "noFallthroughCasesInSwitch": true,
26
+ "noImplicitAny": false,
27
+ "strictNullChecks": true,
28
+ "strictFunctionTypes": false,
29
+ "strictPropertyInitialization": true,
30
+
31
+ "paths": {
32
+ "@/*": ["./src/*"]
33
+ },
34
+ },
35
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
36
+ "references": [{ "path": "./tsconfig.node.json" }]
37
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "module": "ESNext",
5
+ "moduleResolution": "node",
6
+ "allowSyntheticDefaultImports": true
7
+ },
8
+ "include": ["vite.config.ts"]
9
+ }
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import path from 'path';
3
+
4
+ export default defineConfig({
5
+ resolve: {
6
+ alias: {
7
+ '@': path.resolve(__dirname, 'src'),
8
+ },
9
+ },
10
+ test: {
11
+ globals: true,
12
+ include: ['tests/**/*.test.ts'],
13
+ environment: 'node',
14
+ },
15
+ });