@ultimabase/testing 0.0.0-stage → 0.1.1

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 (119) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -2
  3. package/dist/aggregates.d.ts +3 -0
  4. package/dist/aggregates.d.ts.map +1 -0
  5. package/dist/aggregates.js +99 -0
  6. package/dist/as-user.d.ts +13 -0
  7. package/dist/as-user.d.ts.map +1 -0
  8. package/dist/as-user.js +17 -0
  9. package/dist/bulk-returns.d.ts +3 -0
  10. package/dist/bulk-returns.d.ts.map +1 -0
  11. package/dist/bulk-returns.js +125 -0
  12. package/dist/cascades.d.ts +3 -0
  13. package/dist/cascades.d.ts.map +1 -0
  14. package/dist/cascades.js +269 -0
  15. package/dist/cases.d.ts +3 -0
  16. package/dist/cases.d.ts.map +1 -0
  17. package/dist/cases.js +60 -0
  18. package/dist/counts.d.ts +3 -0
  19. package/dist/counts.d.ts.map +1 -0
  20. package/dist/counts.js +158 -0
  21. package/dist/days.d.ts +3 -0
  22. package/dist/days.d.ts.map +1 -0
  23. package/dist/days.js +74 -0
  24. package/dist/decimals.d.ts +3 -0
  25. package/dist/decimals.d.ts.map +1 -0
  26. package/dist/decimals.js +70 -0
  27. package/dist/distincts.d.ts +3 -0
  28. package/dist/distincts.d.ts.map +1 -0
  29. package/dist/distincts.js +80 -0
  30. package/dist/feeds.d.ts +3 -0
  31. package/dist/feeds.d.ts.map +1 -0
  32. package/dist/feeds.js +164 -0
  33. package/dist/groups.d.ts +3 -0
  34. package/dist/groups.d.ts.map +1 -0
  35. package/dist/groups.js +149 -0
  36. package/dist/index.d.ts +5 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +5 -0
  39. package/dist/json-paths.d.ts +3 -0
  40. package/dist/json-paths.d.ts.map +1 -0
  41. package/dist/json-paths.js +143 -0
  42. package/dist/lists.d.ts +3 -0
  43. package/dist/lists.d.ts.map +1 -0
  44. package/dist/lists.js +234 -0
  45. package/dist/nested.d.ts +3 -0
  46. package/dist/nested.d.ts.map +1 -0
  47. package/dist/nested.js +152 -0
  48. package/dist/numbers.d.ts +3 -0
  49. package/dist/numbers.d.ts.map +1 -0
  50. package/dist/numbers.js +92 -0
  51. package/dist/projection.d.ts +3 -0
  52. package/dist/projection.d.ts.map +1 -0
  53. package/dist/projection.js +53 -0
  54. package/dist/reads.d.ts +3 -0
  55. package/dist/reads.d.ts.map +1 -0
  56. package/dist/reads.js +312 -0
  57. package/dist/relation-filters.d.ts +3 -0
  58. package/dist/relation-filters.d.ts.map +1 -0
  59. package/dist/relation-filters.js +116 -0
  60. package/dist/round-trips.d.ts +3 -0
  61. package/dist/round-trips.d.ts.map +1 -0
  62. package/dist/round-trips.js +284 -0
  63. package/dist/searches.d.ts +3 -0
  64. package/dist/searches.d.ts.map +1 -0
  65. package/dist/searches.js +57 -0
  66. package/dist/silent.d.ts +3 -0
  67. package/dist/silent.d.ts.map +1 -0
  68. package/dist/silent.js +7 -0
  69. package/dist/sorts.d.ts +3 -0
  70. package/dist/sorts.d.ts.map +1 -0
  71. package/dist/sorts.js +108 -0
  72. package/dist/suite.d.ts +7 -0
  73. package/dist/suite.d.ts.map +1 -0
  74. package/dist/suite.js +15 -0
  75. package/dist/test-adapter.d.ts +11 -0
  76. package/dist/test-adapter.d.ts.map +1 -0
  77. package/dist/test-adapter.js +55 -0
  78. package/dist/test-application.d.ts +17 -0
  79. package/dist/test-application.d.ts.map +1 -0
  80. package/dist/test-application.js +68 -0
  81. package/dist/transactions.d.ts +3 -0
  82. package/dist/transactions.d.ts.map +1 -0
  83. package/dist/transactions.js +61 -0
  84. package/dist/virtuals.d.ts +3 -0
  85. package/dist/virtuals.d.ts.map +1 -0
  86. package/dist/virtuals.js +135 -0
  87. package/dist/writes.d.ts +3 -0
  88. package/dist/writes.d.ts.map +1 -0
  89. package/dist/writes.js +219 -0
  90. package/package.json +40 -3
  91. package/src/aggregates.ts +128 -0
  92. package/src/as-user.ts +26 -0
  93. package/src/bulk-returns.ts +151 -0
  94. package/src/cascades.ts +338 -0
  95. package/src/cases.ts +76 -0
  96. package/src/counts.ts +223 -0
  97. package/src/days.ts +107 -0
  98. package/src/decimals.ts +102 -0
  99. package/src/distincts.ts +107 -0
  100. package/src/feeds.ts +197 -0
  101. package/src/groups.ts +185 -0
  102. package/src/index.ts +6 -0
  103. package/src/json-paths.ts +185 -0
  104. package/src/lists.ts +304 -0
  105. package/src/nested.ts +187 -0
  106. package/src/numbers.ts +125 -0
  107. package/src/projection.ts +65 -0
  108. package/src/reads.ts +389 -0
  109. package/src/relation-filters.ts +151 -0
  110. package/src/round-trips.ts +392 -0
  111. package/src/searches.ts +82 -0
  112. package/src/silent.ts +11 -0
  113. package/src/sorts.ts +143 -0
  114. package/src/suite.ts +21 -0
  115. package/src/test-adapter.ts +68 -0
  116. package/src/test-application.ts +93 -0
  117. package/src/transactions.ts +76 -0
  118. package/src/virtuals.ts +184 -0
  119. package/src/writes.ts +315 -0
package/dist/counts.js ADDED
@@ -0,0 +1,158 @@
1
+ import { defineModel, master } from 'ultimabase';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { suiteUser } from './as-user.js';
4
+ import { started } from './suite.js';
5
+ const M = { as: master };
6
+ const user = { as: suiteUser('00000000-0000-4000-8000-000000000000') };
7
+ const open = () => true;
8
+ const Firm = defineModel({
9
+ name: 'Firm',
10
+ fields: (f) => ({ name: f.string(), authors: f.many(() => Author) }),
11
+ }).with({ access: { read: open } });
12
+ /** A user reads the authors who are not retired. */
13
+ const Author = defineModel({
14
+ name: 'Author',
15
+ fields: (f) => ({
16
+ name: f.string(),
17
+ retired: f.boolean({ default: false }),
18
+ firm: f.one(() => Firm, { optional: true }),
19
+ articles: f.many(() => Article),
20
+ }),
21
+ }).with({ access: { read: ({ where }) => where({ retired: false }) } });
22
+ const Article = defineModel({
23
+ name: 'Article',
24
+ fields: (f) => ({
25
+ title: f.string({ unique: true }),
26
+ author: f.one(() => Author, { optional: true }),
27
+ remarks: f.many(() => Remark),
28
+ labels: f.many(() => Label),
29
+ }),
30
+ }).with({ access: { read: open } });
31
+ /** A user reads the remarks that are not hidden. */
32
+ const Remark = defineModel({
33
+ name: 'Remark',
34
+ fields: (f) => ({
35
+ body: f.string(),
36
+ hidden: f.boolean({ default: false }),
37
+ article: f.one(() => Article, { onDelete: 'cascade' }),
38
+ }),
39
+ }).with({ access: { read: ({ where }) => where({ hidden: false }) } });
40
+ const Label = defineModel({
41
+ name: 'Label',
42
+ fields: (f) => ({ text: f.string(), articles: f.many(() => Article) }),
43
+ }).with({ access: { read: open } });
44
+ const titles = (rows) => rows.map((row) => row.title);
45
+ const connect = (id) => ({ connect: { id } });
46
+ /** The articles' titles, by their author's firm name, then their title. */
47
+ const byFirm = async (direction, as = M) => titles(await Article.findMany({
48
+ orderBy: [{ author: { firm: { name: direction } } }, { title: 'asc' }],
49
+ }, as));
50
+ export function counts(create) {
51
+ describe('counts and sorts through relations', () => {
52
+ started(create, [Firm, Author, Article, Remark, Label]);
53
+ let ada = '';
54
+ beforeEach(async () => {
55
+ const zeta = await Firm.create({ data: { name: 'Zeta' } }, M);
56
+ const acme = await Firm.create({ data: { name: 'Acme' } }, M);
57
+ ada = (await Author.create({ data: { name: 'Ada', firm: connect(zeta.id) } }, M)).id;
58
+ const bob = await Author.create({ data: { name: 'Bob', retired: true, firm: connect(acme.id) } }, M);
59
+ await Author.create({ data: { name: 'Cy' } }, M);
60
+ const ts = await Label.create({ data: { text: 'ts' } }, M);
61
+ const go = await Label.create({ data: { text: 'go' } }, M);
62
+ const article = async (title, author, remarks, labels) => Article.create({
63
+ data: {
64
+ title,
65
+ ...(author === undefined ? {} : { author: connect(author) }),
66
+ remarks: { create: remarks },
67
+ labels: { connect: labels.map((id) => ({ id })) },
68
+ },
69
+ }, M);
70
+ const hidden = true;
71
+ await article('One', ada, [{ body: 'r1' }, { body: 'r2', hidden }, { body: 'x', hidden }], [ts.id, go.id]);
72
+ await article('Two', bob.id, [], [ts.id]);
73
+ await article('Three', ada, [{ body: 'r3' }, { body: 'r4' }], []);
74
+ await article('Four', undefined, [], []);
75
+ });
76
+ it('counts every many relation, or the ones it names, filtered', async () => {
77
+ const authors = await Author.findMany({ orderBy: { name: 'asc' }, include: { _count: true } }, M);
78
+ expect(authors.map((row) => [row.name, row._count])).toEqual([
79
+ ['Ada', { articles: 2 }],
80
+ ['Bob', { articles: 1 }],
81
+ ['Cy', { articles: 0 }],
82
+ ]);
83
+ const articles = await Article.findMany({
84
+ orderBy: { title: 'asc' },
85
+ select: {
86
+ title: true,
87
+ _count: {
88
+ select: {
89
+ remarks: { where: { body: { startsWith: 'r' } } },
90
+ labels: true,
91
+ },
92
+ },
93
+ },
94
+ }, M);
95
+ expect(articles).toEqual([
96
+ { title: 'Four', _count: { remarks: 0, labels: 0 } },
97
+ { title: 'One', _count: { remarks: 2, labels: 2 } },
98
+ { title: 'Three', _count: { remarks: 2, labels: 0 } },
99
+ { title: 'Two', _count: { remarks: 0, labels: 1 } },
100
+ ]);
101
+ });
102
+ it('counts at any depth, the rows the caller reads', async () => {
103
+ const args = {
104
+ where: { id: ada },
105
+ include: {
106
+ articles: {
107
+ orderBy: { title: 'asc' },
108
+ include: { _count: { select: { remarks: true } } },
109
+ },
110
+ },
111
+ };
112
+ const counted = async (as) => (await Author.findUnique(args, as))?.articles.map((row) => [
113
+ row.title,
114
+ row._count.remarks,
115
+ ]);
116
+ expect(await counted(M)).toEqual([
117
+ ['One', 3],
118
+ ['Three', 2],
119
+ ]);
120
+ expect(await counted(user)).toEqual([
121
+ ['One', 1],
122
+ ['Three', 2],
123
+ ]);
124
+ });
125
+ it('sorts through one relations, an absent row last ascending', async () => {
126
+ expect(await byFirm('asc')).toEqual(['Two', 'One', 'Three', 'Four']);
127
+ expect(await byFirm('desc')).toEqual(['Four', 'One', 'Three', 'Two']);
128
+ // Bob, whom a user does not read, sorts as an absent author.
129
+ expect(await byFirm('asc', user)).toEqual(['One', 'Three', 'Four', 'Two']);
130
+ });
131
+ it('sorts by a count of the rows the caller reads', async () => {
132
+ const by = async (as) => titles(await Article.findMany({
133
+ where: { remarks: { some: {} } },
134
+ orderBy: { remarks: { _count: 'desc' } },
135
+ }, as));
136
+ expect(await by(M)).toEqual(['One', 'Three']);
137
+ expect(await by(user)).toEqual(['Three', 'One']);
138
+ const authors = await Author.findMany({ orderBy: [{ articles: { _count: 'desc' } }, { name: 'asc' }] }, M);
139
+ expect(authors.map((row) => row.name)).toEqual(['Ada', 'Bob', 'Cy']);
140
+ });
141
+ it('pages exactly from a cursor through a relation sort', async () => {
142
+ const orderBy = [
143
+ { author: { name: 'desc' } },
144
+ { remarks: { _count: 'asc' } },
145
+ ];
146
+ const all = titles(await Article.findMany({ orderBy }, M));
147
+ expect(all).toEqual(['Four', 'Two', 'Three', 'One']);
148
+ const rows = await Article.findMany({ orderBy }, M);
149
+ for (const [at, row] of rows.entries()) {
150
+ const cursor = { id: row.id };
151
+ const after = await Article.findMany({ orderBy, cursor, take: 2 }, M);
152
+ expect(titles(after)).toEqual(all.slice(at, at + 2));
153
+ const before = await Article.findMany({ orderBy, cursor, take: -2 }, M);
154
+ expect(titles(before)).toEqual(all.slice(Math.max(0, at - 1), at + 1));
155
+ }
156
+ });
157
+ });
158
+ }
package/dist/days.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function calendarDays(create: () => Adapter): void;
3
+ //# sourceMappingURL=days.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"days.d.ts","sourceRoot":"","sources":["../src/days.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,YAAY,CAAA;AA2BnD,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CA6ExD"}
package/dist/days.js ADDED
@@ -0,0 +1,74 @@
1
+ import { defineModel, master } from 'ultimabase';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { started } from './suite.js';
4
+ const M = { as: master };
5
+ const Shift = defineModel({
6
+ name: 'Shift',
7
+ fields: (f) => ({
8
+ on: f.date(),
9
+ off: f.date({ optional: true, unique: true }),
10
+ team: f.string(),
11
+ }),
12
+ });
13
+ const days = (rows) => rows.map((row) => row.on);
14
+ const rows = [
15
+ { on: '2026-07-31', team: 'a' },
16
+ { on: '0001-01-01', team: 'b', off: '2026-12-25' },
17
+ { on: '2024-02-29', team: 'a' },
18
+ { on: '9999-12-31', team: 'b' },
19
+ { on: '2026-07-31', team: 'b' },
20
+ ];
21
+ export function calendarDays(create) {
22
+ describe('calendar days', () => {
23
+ started(create, [Shift]);
24
+ it('reads each day as written, and finds it unique', async () => {
25
+ const created = await Shift.create({ data: { on: '2026-07-31', team: 'a', off: '2026-08-01' } }, M);
26
+ expect(created).toMatchObject({ on: '2026-07-31', off: '2026-08-01' });
27
+ const updated = await Shift.update({ where: { id: created.id }, data: { on: '0001-01-01', off: null } }, M);
28
+ expect(updated).toMatchObject({ on: '0001-01-01', off: null });
29
+ await Shift.update({ where: { id: created.id }, data: { off: '2024-02-29' } }, M);
30
+ const found = await Shift.findUnique({ where: { off: '2024-02-29' } }, M);
31
+ expect(found).toMatchObject({ id: created.id, on: '0001-01-01' });
32
+ });
33
+ it('filters and sorts in the calendar order', async () => {
34
+ await Shift.createMany({ data: rows }, M);
35
+ const sorted = await Shift.findMany({ orderBy: [{ on: 'desc' }, { team: 'asc' }] }, M);
36
+ expect(days(sorted)).toEqual([
37
+ '9999-12-31',
38
+ '2026-07-31',
39
+ '2026-07-31',
40
+ '2024-02-29',
41
+ '0001-01-01',
42
+ ]);
43
+ const where = { on: { gt: '0001-01-01', lte: '2026-07-31' } };
44
+ expect(await Shift.count({ where }, M)).toBe(3);
45
+ const listed = { on: { in: ['2024-02-29', '9999-12-31'] } };
46
+ expect(await Shift.count({ where: listed }, M)).toBe(2);
47
+ const others = { on: { notIn: ['2026-07-31'], not: '0001-01-01' } };
48
+ expect(await Shift.count({ where: others }, M)).toBe(2);
49
+ expect(await Shift.count({ where: { on: '2026-07-31' } }, M)).toBe(2);
50
+ });
51
+ it('groups, picks one row per day and takes the least and the most', async () => {
52
+ await Shift.createMany({ data: rows }, M);
53
+ const groups = await Shift.groupBy({ by: ['on'], _count: true, orderBy: { on: 'asc' } }, M);
54
+ expect(groups).toStrictEqual([
55
+ { on: '0001-01-01', _count: 1 },
56
+ { on: '2024-02-29', _count: 1 },
57
+ { on: '2026-07-31', _count: 2 },
58
+ { on: '9999-12-31', _count: 1 },
59
+ ]);
60
+ const distinct = await Shift.findMany({ distinct: ['on'], orderBy: { on: 'asc' } }, M);
61
+ expect(days(distinct)).toEqual([
62
+ '0001-01-01',
63
+ '2024-02-29',
64
+ '2026-07-31',
65
+ '9999-12-31',
66
+ ]);
67
+ const result = await Shift.aggregate({ _min: { on: true, off: true }, _max: { on: true, off: true } }, M);
68
+ expect(result).toEqual({
69
+ _min: { on: '0001-01-01', off: '2026-12-25' },
70
+ _max: { on: '9999-12-31', off: '2026-12-25' },
71
+ });
72
+ });
73
+ });
74
+ }
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function decimals(create: () => Adapter): void;
3
+ //# sourceMappingURL=decimals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decimals.d.ts","sourceRoot":"","sources":["../src/decimals.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,YAAY,CAAA;AAmBnD,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CAgFpD"}
@@ -0,0 +1,70 @@
1
+ import { defineModel, master } from 'ultimabase';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { started } from './suite.js';
4
+ const M = { as: master };
5
+ const Price = defineModel({
6
+ name: 'Price',
7
+ fields: (f) => ({
8
+ amount: f.decimal({ precision: 6, scale: 2 }),
9
+ code: f.decimal({ precision: 4, scale: 1, optional: true, unique: true }),
10
+ exact: f.decimal({ precision: 30, scale: 10, optional: true }),
11
+ }),
12
+ });
13
+ const amounts = (rows) => rows.map((row) => row.amount);
14
+ export function decimals(create) {
15
+ describe('decimals', () => {
16
+ started(create, [Price]);
17
+ it('stores each value at its scale, beyond a double', async () => {
18
+ const exact = '12345678901234567890.0123456789';
19
+ const created = await Price.create({ data: { amount: '012.5', code: '-0', exact } }, M);
20
+ expect(created).toMatchObject({ amount: '12.50', code: '0.0', exact });
21
+ const updated = await Price.update({ where: { id: created.id }, data: { amount: '7.500', exact: null } }, M);
22
+ expect(updated).toMatchObject({ amount: '7.50', exact: null });
23
+ const found = await Price.findUnique({ where: { code: '0' } }, M);
24
+ expect(found?.id).toBe(created.id);
25
+ });
26
+ it('filters and sorts by value, not by text', async () => {
27
+ const nine = await Price.create({ data: { amount: '9.5' } }, M);
28
+ await Price.createMany({ data: ['10', '-2.25', '100'].map((amount) => ({ amount })) }, M);
29
+ const sorted = await Price.findMany({ orderBy: { amount: 'asc' } }, M);
30
+ expect(amounts(sorted)).toEqual(['-2.25', '9.50', '10.00', '100.00']);
31
+ const where = { amount: { gt: '9.5', lte: '100.00' } };
32
+ expect(amounts(await Price.findMany({ where }, M))).toEqual([
33
+ '10.00',
34
+ '100.00',
35
+ ]);
36
+ const listed = { amount: { in: ['10', '-2.250'] } };
37
+ expect(await Price.count({ where: listed }, M)).toBe(2);
38
+ const after = await Price.findMany({
39
+ orderBy: { amount: 'desc' },
40
+ cursor: { id: nine.id },
41
+ skip: 1,
42
+ }, M);
43
+ expect(amounts(after)).toEqual(['-2.25']);
44
+ const before = await Price.findMany({ orderBy: { amount: 'asc' }, cursor: { id: nine.id } }, M);
45
+ expect(amounts(before)).toEqual(['9.50', '10.00', '100.00']);
46
+ });
47
+ it('sums exactly, at the scale, and compares by value', async () => {
48
+ await Price.createMany({
49
+ data: [
50
+ { amount: '0.10', exact: '0.1' },
51
+ { amount: '0.20', exact: '99999999999999999999.9999999999' },
52
+ { amount: '-10' },
53
+ { amount: '9' },
54
+ ],
55
+ }, M);
56
+ const result = await Price.aggregate({
57
+ _sum: { amount: true, exact: true },
58
+ _avg: { amount: true },
59
+ _min: { amount: true },
60
+ _max: { amount: true, code: true },
61
+ }, M);
62
+ expect(result).toEqual({
63
+ _sum: { amount: '-0.70', exact: '100000000000000000000.0999999999' },
64
+ _avg: { amount: -0.175 },
65
+ _min: { amount: '-10.00' },
66
+ _max: { amount: '9.00', code: null },
67
+ });
68
+ });
69
+ });
70
+ }
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function distincts(create: () => Adapter): void;
3
+ //# sourceMappingURL=distincts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"distincts.d.ts","sourceRoot":"","sources":["../src/distincts.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,YAAY,CAAA;AA+BnD,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CAwErD"}
@@ -0,0 +1,80 @@
1
+ import { defineModel, master } from 'ultimabase';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import { suiteUser } from './as-user.js';
4
+ import { started } from './suite.js';
5
+ const M = { as: master };
6
+ const user = { as: suiteUser('00000000-0000-4000-8000-000000000000') };
7
+ /** A user reads the clubs that are not hidden. */
8
+ const Club = defineModel({
9
+ name: 'Club',
10
+ fields: (f) => ({
11
+ hidden: f.boolean({ default: false }),
12
+ visits: f.many(() => Visit),
13
+ }),
14
+ }).with({ access: { read: ({ where }) => where({ hidden: false }) } });
15
+ const Visit = defineModel({
16
+ name: 'Visit',
17
+ fields: (f) => ({
18
+ score: f.number({ integer: true }),
19
+ region: f.string({ optional: true }),
20
+ club: f.one(() => Club, { optional: true }),
21
+ }),
22
+ }).with({ access: { read: () => true } });
23
+ const select = { score: true };
24
+ const scores = (rows) => rows.map((row) => row.score);
25
+ export function distincts(create) {
26
+ describe('distincts', () => {
27
+ started(create, [Club, Visit]);
28
+ beforeEach(async () => {
29
+ const red = (await Club.create({ data: {} }, M)).id;
30
+ const blue = (await Club.create({ data: { hidden: true } }, M)).id;
31
+ const rows = [
32
+ { score: 1, region: 'n', club: red },
33
+ { score: 2, region: 'n', club: blue },
34
+ { score: 3, region: 's', club: red },
35
+ { score: 4 },
36
+ { score: 5, region: 's', club: blue },
37
+ { score: 6, club: red },
38
+ ];
39
+ for (const { club, ...row } of rows) {
40
+ const data = club === undefined ? row : { ...row, club: { connect: { id: club } } };
41
+ await Visit.create({ data }, M);
42
+ }
43
+ });
44
+ it('keeps the first row of each key in the order, the rows with no value together', async () => {
45
+ const first = await Visit.findMany({ distinct: ['region'], orderBy: { score: 'asc' }, select }, M);
46
+ expect(scores(first)).toStrictEqual([1, 3, 4]);
47
+ const last = await Visit.findMany({ distinct: ['region'], orderBy: { score: 'desc' }, select }, M);
48
+ expect(scores(last)).toStrictEqual([6, 5, 2]);
49
+ });
50
+ it('filters before it keeps, and pages after', async () => {
51
+ const filtered = await Visit.findMany({
52
+ distinct: ['region'],
53
+ where: { score: { gte: 3 } },
54
+ orderBy: { score: 'asc' },
55
+ select,
56
+ }, M);
57
+ expect(scores(filtered)).toStrictEqual([3, 4]);
58
+ const args = {
59
+ distinct: ['region'],
60
+ orderBy: { score: 'desc' },
61
+ select,
62
+ };
63
+ const paged = await Visit.findMany({ ...args, skip: 1, take: 1 }, M);
64
+ expect(scores(paged)).toStrictEqual([5]);
65
+ const back = await Visit.findMany({ ...args, take: -1 }, M);
66
+ expect(scores(back)).toStrictEqual([2]);
67
+ });
68
+ it('compares a relation by its target, none when unread', async () => {
69
+ const args = {
70
+ distinct: ['club'],
71
+ orderBy: { score: 'asc' },
72
+ select,
73
+ };
74
+ expect(scores(await Visit.findMany(args, M))).toStrictEqual([1, 2, 4]);
75
+ expect(scores(await Visit.findMany(args, user))).toStrictEqual([1, 2]);
76
+ const both = await Visit.findMany({ ...args, distinct: ['region', 'club'] }, M);
77
+ expect(scores(both)).toStrictEqual([1, 2, 3, 4, 5, 6]);
78
+ });
79
+ });
80
+ }
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function feeds(create: () => Adapter): void;
3
+ //# sourceMappingURL=feeds.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feeds.d.ts","sourceRoot":"","sources":["../src/feeds.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAW,MAAM,YAAY,CAAA;AA2ElD,wBAAgB,KAAK,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CAuHjD"}
package/dist/feeds.js ADDED
@@ -0,0 +1,164 @@
1
+ import { defineApp, defineModel, definePlugin, master, transaction, } from 'ultimabase';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { silent } from './silent.js';
4
+ const M = { as: master };
5
+ const Card = defineModel({
6
+ name: 'Card',
7
+ fields: (f) => ({
8
+ title: f.string({ unique: true }),
9
+ rank: f.number({ integer: true, optional: true }),
10
+ tags: f.many(() => Tag),
11
+ }),
12
+ });
13
+ const Tag = defineModel({
14
+ name: 'Tag',
15
+ fields: (f) => ({ label: f.string(), cards: f.many(() => Card) }),
16
+ });
17
+ const long = Array.from({ length: 30 }, (_, index) => `field${index}`.padEnd(52, 'x'));
18
+ const Wide = defineModel({
19
+ name: 'Wide',
20
+ fields: (f) => Object.fromEntries(long.map((name) => [name, f.string({ optional: true })])),
21
+ });
22
+ /** Written last: once its insert arrives, so have the changes before it. */
23
+ const Mark = defineModel({
24
+ name: 'Mark',
25
+ fields: (f) => ({ at: f.number({ integer: true }) }),
26
+ });
27
+ const manifest = {
28
+ name: 'watcher',
29
+ version: '1.0.0',
30
+ peerDependencies: { ultimabase: '*' },
31
+ };
32
+ /** The changes `seen` holds, one entry per kind and target, lists sorted. */
33
+ function merged(seen) {
34
+ const entries = new Map();
35
+ for (const change of seen) {
36
+ const target = 'field' in change ? change.field : change.fields?.toSorted();
37
+ const key = JSON.stringify([change.kind, change.model, target]);
38
+ const rows = 'field' in change ? change.pairs : change.ids;
39
+ const known = entries.get(key);
40
+ const listed = rows === undefined || (known !== undefined && known.listed === undefined)
41
+ ? undefined
42
+ : [
43
+ ...(known?.listed ?? []),
44
+ ...rows.map((row) => (typeof row === 'string' ? row : row.join())),
45
+ ];
46
+ entries.set(key, listed === undefined ? { change } : { change, listed });
47
+ }
48
+ return [...entries.values()].map(({ change, listed }) => {
49
+ const { kind, model } = change;
50
+ const rows = listed?.toSorted();
51
+ if ('field' in change)
52
+ return { kind, model, field: change.field, rows };
53
+ return { kind, model, fields: change.fields?.toSorted(), rows };
54
+ });
55
+ }
56
+ export function feeds(create) {
57
+ describe('the change feed', () => {
58
+ let seen = [];
59
+ let marks = 0;
60
+ let app;
61
+ const watcher = definePlugin({
62
+ name: 'watcher',
63
+ manifest,
64
+ changed: (changes) => {
65
+ seen.push(...changes);
66
+ },
67
+ });
68
+ beforeEach(async () => {
69
+ app = defineApp({
70
+ database: create(),
71
+ models: [Card, Tag, Wide, Mark],
72
+ plugins: [watcher.plugin()],
73
+ logger: silent,
74
+ });
75
+ await app.start();
76
+ seen = [];
77
+ });
78
+ afterEach(async () => app.stop());
79
+ /** The changes the commits so far made, once all of them arrived. */
80
+ const settled = async () => {
81
+ marks += 1;
82
+ const { id } = await Mark.create({ data: { at: marks } }, M);
83
+ await vi.waitFor(() => {
84
+ expect(seen.some((change) => 'ids' in change && change.ids?.includes(id))).toBe(true);
85
+ });
86
+ const changes = merged(seen.filter((change) => change.model !== 'Mark'));
87
+ seen = [];
88
+ return changes;
89
+ };
90
+ it('reports the rows each commit inserted, updated and deleted', async () => {
91
+ const a = await Card.create({ data: { title: 'a' } }, M);
92
+ const b = await Card.create({ data: { title: 'b' } }, M);
93
+ await Card.update({ where: { id: a.id }, data: { rank: 2 } }, M);
94
+ await Card.delete({ where: { id: b.id } }, M);
95
+ expect(await settled()).toEqual([
96
+ { kind: 'insert', model: 'Card', rows: [a.id, b.id].toSorted() },
97
+ {
98
+ kind: 'update',
99
+ model: 'Card',
100
+ fields: ['rank', 'updatedAt'],
101
+ rows: [a.id],
102
+ },
103
+ { kind: 'delete', model: 'Card', rows: [b.id] },
104
+ ]);
105
+ });
106
+ it('reports the links a write added or removed, not those it found', async () => {
107
+ const card = await Card.create({ data: { title: 'a' } }, M);
108
+ const tag = await Tag.create({ data: { label: 'red' } }, M);
109
+ await settled();
110
+ const tags = { connect: [{ id: tag.id }] };
111
+ await Card.update({ where: { id: card.id }, data: { tags } }, M);
112
+ await Card.update({ where: { id: card.id }, data: { tags } }, M);
113
+ const pair = [card.id, tag.id].join();
114
+ const link = { model: 'Card', field: 'tags', rows: [pair] };
115
+ const updated = { kind: 'update', model: 'Card', fields: ['updatedAt'] };
116
+ expect(await settled()).toEqual([
117
+ { ...updated, rows: [card.id, card.id] },
118
+ { kind: 'link', ...link },
119
+ ]);
120
+ const disconnect = [{ id: tag.id }];
121
+ await Card.update({ where: { id: card.id }, data: { tags: { disconnect } } }, M);
122
+ expect(await settled()).toEqual([
123
+ { ...updated, rows: [card.id] },
124
+ { kind: 'unlink', ...link },
125
+ ]);
126
+ });
127
+ it('reports nothing of a rollback, a failed write or an empty update', async () => {
128
+ await Card.create({ data: { title: 'a' } }, M);
129
+ await settled();
130
+ const failed = transaction(async () => {
131
+ await Card.create({ data: { title: 'b' } }, M);
132
+ throw new Error('Stop.');
133
+ });
134
+ await expect(failed).rejects.toThrow('Stop.');
135
+ const taken = Card.create({ data: { title: 'a' } }, M);
136
+ await expect(taken).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
137
+ await Card.updateMany({ where: { title: 'none' }, data: { rank: 1 } }, M);
138
+ expect(await settled()).toEqual([]);
139
+ });
140
+ it("reports a transaction's writes once it commits", async () => {
141
+ const [a, b] = await transaction(async () => [
142
+ await Card.create({ data: { title: 'a' } }, M),
143
+ await Card.create({ data: { title: 'b' } }, M),
144
+ ]);
145
+ expect(await settled()).toEqual([
146
+ { kind: 'insert', model: 'Card', rows: [a?.id, b?.id].toSorted() },
147
+ ]);
148
+ });
149
+ it('lists no ids past a thousand, no fields past their limit', async () => {
150
+ const data = Array.from({ length: 1001 }, (_, index) => ({
151
+ title: `${index}`,
152
+ }));
153
+ await Card.createMany({ data }, M);
154
+ const wide = await Wide.create({ data: {} }, M);
155
+ const set = Object.fromEntries(long.map((name) => [name, 'v']));
156
+ await Wide.update({ where: { id: wide.id }, data: set }, M);
157
+ expect(await settled()).toEqual([
158
+ { kind: 'insert', model: 'Card', rows: undefined },
159
+ { kind: 'insert', model: 'Wide', rows: [wide.id] },
160
+ { kind: 'update', model: 'Wide', fields: undefined, rows: [wide.id] },
161
+ ]);
162
+ });
163
+ });
164
+ }
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from 'ultimabase';
2
+ export declare function groups(create: () => Adapter): void;
3
+ //# sourceMappingURL=groups.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"groups.d.ts","sourceRoot":"","sources":["../src/groups.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,YAAY,CAAA;AAmCnD,wBAAgB,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,IAAI,CAiJlD"}