@rebasepro/server-mongo 0.16.0 → 0.16.1-canary.g0d7af95
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/MongoBootstrapper.d.ts +3 -3
- package/dist/factory.d.ts +4 -4
- package/dist/factory.d.ts.map +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.es.js +99 -13
- package/dist/index.es.js.map +1 -1
- package/dist/schema/plan-schema-change.d.ts +49 -0
- package/dist/schema/plan-schema-change.d.ts.map +1 -0
- package/dist/services/MongoDriver.d.ts +3 -3
- package/dist/services/MongoHistoryService.d.ts.map +1 -1
- package/dist/services/MongoRealtimeService.d.ts +1 -1
- package/dist/websocket.d.ts +2 -2
- package/package.json +6 -6
- package/src/factory.ts +15 -2
- package/src/schema/plan-schema-change.ts +159 -0
- package/src/services/MongoHistoryService.ts +28 -13
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planning a collection change for MongoDB.
|
|
3
|
+
*
|
|
4
|
+
* Shorter than the Postgres one by the whole of its difficulty. A document
|
|
5
|
+
* database has no table to alter: adding a property adds nothing, removing one
|
|
6
|
+
* removes nothing, and a document written yesterday is still valid tomorrow. So
|
|
7
|
+
* the plan is the commit, and there is no DDL to run, refuse or get wrong.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this exists at all, rather than leaving Mongo unsupported
|
|
10
|
+
*
|
|
11
|
+
* Because "unsupported" was the wrong answer, and it was the answer only
|
|
12
|
+
* because nothing had been written here. `isSchemaEditingAdmin` is a structural
|
|
13
|
+
* check — a driver either offers `planSchemaChange` or it does not — so a Mongo
|
|
14
|
+
* project got `SCHEMA_EDITING_UNSUPPORTED` and fell back to the source-only
|
|
15
|
+
* editor, which is off in production. A schemaless database is the one place
|
|
16
|
+
* where changing a collection against a *running* backend is completely safe,
|
|
17
|
+
* and it was the one place it did not work.
|
|
18
|
+
*
|
|
19
|
+
* ## What a verdict means here
|
|
20
|
+
*
|
|
21
|
+
* Every change is `safe`, because none of them can fail. That is not the
|
|
22
|
+
* classifier being lax: on Postgres a verdict answers "will the database end up
|
|
23
|
+
* matching this configuration", and here it always does — there is nothing in
|
|
24
|
+
* the database that describes the shape.
|
|
25
|
+
*
|
|
26
|
+
* What the changes still say is what happens to the **data**, because that is
|
|
27
|
+
* the part a reader can be surprised by. Removing a property does not delete
|
|
28
|
+
* the field from documents that have it; those documents keep it and the API
|
|
29
|
+
* stops serving it. Saying so is the difference between a reader who knows the
|
|
30
|
+
* data is still there and one who assumes it is gone.
|
|
31
|
+
*/
|
|
32
|
+
import type { CollectionConfig, SchemaChange, SchemaChangePlan } from "@rebasepro/types";
|
|
33
|
+
/**
|
|
34
|
+
* What changed, in the terms a document database makes true.
|
|
35
|
+
*
|
|
36
|
+
* Exported because it is the interesting half and worth testing without a
|
|
37
|
+
* driver: the shape of the answer is the product, and the rest is plumbing.
|
|
38
|
+
*/
|
|
39
|
+
export declare function classifyMongoChanges(before: CollectionConfig[], after: CollectionConfig[]): SchemaChange[];
|
|
40
|
+
/**
|
|
41
|
+
* The plan: a commit, and nothing to run.
|
|
42
|
+
*
|
|
43
|
+
* `files` is empty here and filled by the caller with the rewritten collection
|
|
44
|
+
* source. Postgres adds generated artifacts — a Drizzle schema, declarative
|
|
45
|
+
* DDL — because a stale one breaks the next deploy. MongoDB generates none, so
|
|
46
|
+
* the collection file is the whole of the change.
|
|
47
|
+
*/
|
|
48
|
+
export declare function planMongoSchemaChange(before: CollectionConfig[], after: CollectionConfig[]): Promise<SchemaChangePlan>;
|
|
49
|
+
//# sourceMappingURL=plan-schema-change.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan-schema-change.d.ts","sourceRoot":"","sources":["../../src/schema/plan-schema-change.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,OAAO,KAAK,EACR,gBAAgB,EAChB,YAAY,EACZ,gBAAgB,EACnB,MAAM,kBAAkB,CAAC;AAa1B;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAChC,MAAM,EAAE,gBAAgB,EAAE,EAC1B,KAAK,EAAE,gBAAgB,EAAE,GAC1B,YAAY,EAAE,CA4DhB;AAqBD;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACvC,MAAM,EAAE,gBAAgB,EAAE,EAC1B,KAAK,EAAE,gBAAgB,EAAE,GAC1B,OAAO,CAAC,gBAAgB,CAAC,CAS3B"}
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Db } from "mongodb";
|
|
8
8
|
import { DataDriver, DeleteProps, CollectionConfig, FetchCollectionProps, FetchOneProps, ListenCollectionProps, ListenOneProps, SaveProps, CollectionRegistryInterface, User, RebaseClient, RebaseSdkData } from "@rebasepro/types";
|
|
9
|
-
import { MongoDataService } from "../db/MongoDataService";
|
|
10
|
-
import { MongoRealtimeService } from "./MongoRealtimeService";
|
|
11
|
-
import { MongoHistoryService } from "./MongoHistoryService";
|
|
9
|
+
import { MongoDataService } from "../db/MongoDataService.js";
|
|
10
|
+
import { MongoRealtimeService } from "./MongoRealtimeService.js";
|
|
11
|
+
import { MongoHistoryService } from "./MongoHistoryService.js";
|
|
12
12
|
/**
|
|
13
13
|
* MongoDB DataDriver Delegate
|
|
14
14
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MongoHistoryService.d.ts","sourceRoot":"","sources":["../../src/services/MongoHistoryService.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AA0BvC;;GAEG;AACH,wBAAgB,iBAAiB,CAC7B,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,EAAE,GAAG,IAAI,CA8BjB;AAED,YAAY,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AACpF,OAAO,KAAK,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAExG;;;;;;;;GAQG;AACH,MAAM,WAAW,oBAAqB,SAAQ,IAAI,CAAC,kBAAkB,EAAE,YAAY,CAAC;IAChF,GAAG,CAAC,EAAE,QAAQ,CAAC;IACf,UAAU,EAAE,IAAI,CAAC;CACpB;AAOD,qBAAa,mBAAmB;IAIxB,OAAO,CAAC,EAAE;IAHP,SAAS,EAAE,sBAAsB,CAAC;gBAG7B,EAAE,EAAE,EAAE,EACd,SAAS,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC;IAMzC,aAAa,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;YA0CjD,YAAY;
|
|
1
|
+
{"version":3,"file":"MongoHistoryService.d.ts","sourceRoot":"","sources":["../../src/services/MongoHistoryService.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AA0BvC;;GAEG;AACH,wBAAgB,iBAAiB,CAC7B,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,EAAE,GAAG,IAAI,CA8BjB;AAED,YAAY,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AACpF,OAAO,KAAK,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAExG;;;;;;;;GAQG;AACH,MAAM,WAAW,oBAAqB,SAAQ,IAAI,CAAC,kBAAkB,EAAE,YAAY,CAAC;IAChF,GAAG,CAAC,EAAE,QAAQ,CAAC;IACf,UAAU,EAAE,IAAI,CAAC;CACpB;AAOD,qBAAa,mBAAmB;IAIxB,OAAO,CAAC,EAAE;IAHP,SAAS,EAAE,sBAAsB,CAAC;gBAG7B,EAAE,EAAE,EAAE,EACd,SAAS,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC;IAMzC,aAAa,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;YA0CjD,YAAY;CA8C7B"}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { Db, ChangeStream } from "mongodb";
|
|
8
8
|
import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "@rebasepro/types";
|
|
9
9
|
import { WebSocket } from "ws";
|
|
10
|
-
import type { MongoDriver } from "./MongoDriver";
|
|
10
|
+
import type { MongoDriver } from "./MongoDriver.js";
|
|
11
11
|
/** The acting user for a subscription, as the driver and the socket carry it. */
|
|
12
12
|
export interface SubscriptionAuthContext {
|
|
13
13
|
uid: string;
|
package/dist/websocket.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DatabaseAdmin, AuthAdapter } from "@rebasepro/types";
|
|
2
2
|
import { Server } from "http";
|
|
3
3
|
import type { RebaseAuthConfig } from "@rebasepro/server";
|
|
4
|
-
import { MongoRealtimeService } from "./services/MongoRealtimeService";
|
|
5
|
-
import { MongoDriver } from "./services/MongoDriver";
|
|
4
|
+
import { MongoRealtimeService } from "./services/MongoRealtimeService.js";
|
|
5
|
+
import { MongoDriver } from "./services/MongoDriver.js";
|
|
6
6
|
export declare function createMongoWebSocket(server: Server, realtimeService: MongoRealtimeService, driver: MongoDriver, authConfig?: RebaseAuthConfig, admin?: DatabaseAdmin, authAdapter?: AuthAdapter): void;
|
|
7
7
|
//# sourceMappingURL=websocket.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-mongo",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.16.
|
|
4
|
+
"version": "0.16.1-canary.g0d7af95",
|
|
5
5
|
"description": "MongoDB backend for Rebase",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"mongodb": "^7.5.0",
|
|
42
42
|
"ws": "^8.21.1",
|
|
43
|
-
"@rebasepro/common": "0.16.
|
|
44
|
-
"@rebasepro/types": "0.16.
|
|
45
|
-
"@rebasepro/utils": "0.16.
|
|
43
|
+
"@rebasepro/common": "0.16.1-canary.g0d7af95",
|
|
44
|
+
"@rebasepro/types": "0.16.1-canary.g0d7af95",
|
|
45
|
+
"@rebasepro/utils": "0.16.1-canary.g0d7af95"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/jest": "^30.0.0",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"vite": "^8.1.5"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@rebasepro/server": "0.16.
|
|
58
|
+
"@rebasepro/server": "0.16.1-canary.g0d7af95"
|
|
59
59
|
},
|
|
60
60
|
"peerDependenciesMeta": {
|
|
61
61
|
"@rebasepro/server": {
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"watch": "vite build --watch",
|
|
75
|
-
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
|
|
75
|
+
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../tooling/scripts/add-dts-extensions.mjs dist && node ../../tooling/scripts/assert-build-output.mjs",
|
|
76
76
|
"test:lint": "eslint \"src/**\" --quiet",
|
|
77
77
|
"test": "jest --passWithNoTests --forceExit",
|
|
78
78
|
"clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
|
package/src/factory.ts
CHANGED
|
@@ -13,7 +13,8 @@ import { MongoRealtimeService } from "./services/MongoRealtimeService";
|
|
|
13
13
|
import { MongoDriver } from "./services/MongoDriver";
|
|
14
14
|
import { MongoHistoryService, HistoryRetentionConfig } from "./services/MongoHistoryService";
|
|
15
15
|
import { MongoDBConnection } from "./connection";
|
|
16
|
-
import { BackendConfig, BackendInstance, CollectionRegistryInterface, DataRepository, RealtimeProvider, DatabaseConnection, DatabaseAdmin, DocumentAdmin, SchemaAdmin, HealthCheckResult } from "@rebasepro/types";
|
|
16
|
+
import { BackendConfig, BackendInstance, CollectionRegistryInterface, DataRepository, RealtimeProvider, DatabaseConnection, DatabaseAdmin, DocumentAdmin, SchemaAdmin, SchemaEditingAdmin, HealthCheckResult } from "@rebasepro/types";
|
|
17
|
+
import { planMongoSchemaChange } from "./schema/plan-schema-change";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Configuration for creating a MongoDB backend.
|
|
@@ -161,6 +162,15 @@ export function createMongoBackend(config: MongoBackendConfig): MongoBackendInst
|
|
|
161
162
|
|
|
162
163
|
// Build admin capabilities for MongoDB
|
|
163
164
|
const admin: DatabaseAdmin = {
|
|
165
|
+
// A document database has no table to alter, so a collection change is
|
|
166
|
+
// the commit and nothing else. Offered here because `isSchemaEditingAdmin`
|
|
167
|
+
// is a structural check: without this method a Mongo project was told
|
|
168
|
+
// live schema editing was *unsupported*, when it is the one place the
|
|
169
|
+
// change cannot fail.
|
|
170
|
+
planSchemaChange: async (before, after) => planMongoSchemaChange(
|
|
171
|
+
before as CollectionConfig[],
|
|
172
|
+
after as CollectionConfig[]
|
|
173
|
+
),
|
|
164
174
|
async executeAggregate(pipeline: Record<string, unknown>[]) {
|
|
165
175
|
// Run aggregation on a collection — requires a target collection
|
|
166
176
|
// from the pipeline's $match or $lookup stage:
|
|
@@ -201,7 +211,10 @@ foreignKeys: [],
|
|
|
201
211
|
junctions: [],
|
|
202
212
|
policies: [] };
|
|
203
213
|
}
|
|
204
|
-
|
|
214
|
+
// `satisfies` narrows the literal regardless of the annotation above, so
|
|
215
|
+
// the schema-editing method has to be named here too or it reads as an
|
|
216
|
+
// excess property.
|
|
217
|
+
} satisfies DocumentAdmin & SchemaAdmin & SchemaEditingAdmin;
|
|
205
218
|
|
|
206
219
|
return {
|
|
207
220
|
// Abstract interface implementations
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planning a collection change for MongoDB.
|
|
3
|
+
*
|
|
4
|
+
* Shorter than the Postgres one by the whole of its difficulty. A document
|
|
5
|
+
* database has no table to alter: adding a property adds nothing, removing one
|
|
6
|
+
* removes nothing, and a document written yesterday is still valid tomorrow. So
|
|
7
|
+
* the plan is the commit, and there is no DDL to run, refuse or get wrong.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this exists at all, rather than leaving Mongo unsupported
|
|
10
|
+
*
|
|
11
|
+
* Because "unsupported" was the wrong answer, and it was the answer only
|
|
12
|
+
* because nothing had been written here. `isSchemaEditingAdmin` is a structural
|
|
13
|
+
* check — a driver either offers `planSchemaChange` or it does not — so a Mongo
|
|
14
|
+
* project got `SCHEMA_EDITING_UNSUPPORTED` and fell back to the source-only
|
|
15
|
+
* editor, which is off in production. A schemaless database is the one place
|
|
16
|
+
* where changing a collection against a *running* backend is completely safe,
|
|
17
|
+
* and it was the one place it did not work.
|
|
18
|
+
*
|
|
19
|
+
* ## What a verdict means here
|
|
20
|
+
*
|
|
21
|
+
* Every change is `safe`, because none of them can fail. That is not the
|
|
22
|
+
* classifier being lax: on Postgres a verdict answers "will the database end up
|
|
23
|
+
* matching this configuration", and here it always does — there is nothing in
|
|
24
|
+
* the database that describes the shape.
|
|
25
|
+
*
|
|
26
|
+
* What the changes still say is what happens to the **data**, because that is
|
|
27
|
+
* the part a reader can be surprised by. Removing a property does not delete
|
|
28
|
+
* the field from documents that have it; those documents keep it and the API
|
|
29
|
+
* stops serving it. Saying so is the difference between a reader who knows the
|
|
30
|
+
* data is still there and one who assumes it is gone.
|
|
31
|
+
*/
|
|
32
|
+
import type {
|
|
33
|
+
CollectionConfig,
|
|
34
|
+
SchemaChange,
|
|
35
|
+
SchemaChangePlan
|
|
36
|
+
} from "@rebasepro/types";
|
|
37
|
+
|
|
38
|
+
const bySlug = (collections: CollectionConfig[]): Map<string, CollectionConfig> => {
|
|
39
|
+
const map = new Map<string, CollectionConfig>();
|
|
40
|
+
for (const collection of collections) {
|
|
41
|
+
if (collection.slug) map.set(collection.slug, collection);
|
|
42
|
+
}
|
|
43
|
+
return map;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const propertiesOf = (collection: CollectionConfig): Record<string, unknown> =>
|
|
47
|
+
(collection.properties ?? {}) as Record<string, unknown>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What changed, in the terms a document database makes true.
|
|
51
|
+
*
|
|
52
|
+
* Exported because it is the interesting half and worth testing without a
|
|
53
|
+
* driver: the shape of the answer is the product, and the rest is plumbing.
|
|
54
|
+
*/
|
|
55
|
+
export function classifyMongoChanges(
|
|
56
|
+
before: CollectionConfig[],
|
|
57
|
+
after: CollectionConfig[]
|
|
58
|
+
): SchemaChange[] {
|
|
59
|
+
const previous = bySlug(before);
|
|
60
|
+
const next = bySlug(after);
|
|
61
|
+
const changes: SchemaChange[] = [];
|
|
62
|
+
|
|
63
|
+
for (const [slug, collection] of next) {
|
|
64
|
+
if (!previous.has(slug)) {
|
|
65
|
+
changes.push({
|
|
66
|
+
kind: "add-collection",
|
|
67
|
+
verdict: "safe",
|
|
68
|
+
collection: slug,
|
|
69
|
+
detail: `New collection "${slug}". MongoDB creates it on the first write; nothing ` +
|
|
70
|
+
"is created now."
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const oldProps = propertiesOf(previous.get(slug)!);
|
|
76
|
+
const newProps = propertiesOf(collection);
|
|
77
|
+
|
|
78
|
+
for (const name of Object.keys(newProps)) {
|
|
79
|
+
if (name in oldProps) continue;
|
|
80
|
+
changes.push({
|
|
81
|
+
kind: "add-property",
|
|
82
|
+
verdict: "safe",
|
|
83
|
+
collection: slug,
|
|
84
|
+
property: name,
|
|
85
|
+
detail: `New property "${name}". Existing documents do not have it and are not ` +
|
|
86
|
+
"rewritten; reads return it as absent until something writes it."
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const name of Object.keys(oldProps)) {
|
|
91
|
+
if (name in newProps) continue;
|
|
92
|
+
changes.push({
|
|
93
|
+
kind: "remove-property",
|
|
94
|
+
verdict: "safe",
|
|
95
|
+
collection: slug,
|
|
96
|
+
property: name,
|
|
97
|
+
// The one thing worth being explicit about. On Postgres this is
|
|
98
|
+
// refused because it would drop a column; here nothing is
|
|
99
|
+
// dropped, and a reader who assumes otherwise has it backwards.
|
|
100
|
+
detail: `"${name}" is no longer served. Documents that have it keep it — MongoDB ` +
|
|
101
|
+
"stores no schema, so nothing is removed from the data."
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const [slug] of previous) {
|
|
107
|
+
if (next.has(slug)) continue;
|
|
108
|
+
changes.push({
|
|
109
|
+
kind: "remove-collection",
|
|
110
|
+
verdict: "safe",
|
|
111
|
+
collection: slug,
|
|
112
|
+
detail: `Collection "${slug}" is no longer served. The MongoDB collection and every ` +
|
|
113
|
+
"document in it are left exactly as they are."
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return changes;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A commit message that says what changed rather than that something did. */
|
|
121
|
+
function commitMessage(changes: SchemaChange[]): string {
|
|
122
|
+
if (changes.length === 0) return "chore(schema): no change";
|
|
123
|
+
|
|
124
|
+
const collections = [...new Set(changes.map(c => c.collection))].sort();
|
|
125
|
+
const added = changes.filter(c => c.kind === "add-collection");
|
|
126
|
+
const properties = changes.filter(c => c.kind === "add-property");
|
|
127
|
+
|
|
128
|
+
const subject = added.length === 1 && changes.length === 1
|
|
129
|
+
? `add the ${added[0].collection} collection`
|
|
130
|
+
: properties.length === 1 && changes.length === 1
|
|
131
|
+
? `add ${properties[0].property} to ${properties[0].collection}`
|
|
132
|
+
: collections.length === 1
|
|
133
|
+
? `${changes.length} change(s) to ${collections[0]}`
|
|
134
|
+
: `${changes.length} change(s) across ${collections.length} collections`;
|
|
135
|
+
|
|
136
|
+
return `feat(schema): ${subject}\n\n${changes.map(c => `- ${c.detail}`).join("\n")}\n`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The plan: a commit, and nothing to run.
|
|
141
|
+
*
|
|
142
|
+
* `files` is empty here and filled by the caller with the rewritten collection
|
|
143
|
+
* source. Postgres adds generated artifacts — a Drizzle schema, declarative
|
|
144
|
+
* DDL — because a stale one breaks the next deploy. MongoDB generates none, so
|
|
145
|
+
* the collection file is the whole of the change.
|
|
146
|
+
*/
|
|
147
|
+
export async function planMongoSchemaChange(
|
|
148
|
+
before: CollectionConfig[],
|
|
149
|
+
after: CollectionConfig[]
|
|
150
|
+
): Promise<SchemaChangePlan> {
|
|
151
|
+
const changes = classifyMongoChanges(before, after);
|
|
152
|
+
return {
|
|
153
|
+
files: [],
|
|
154
|
+
statements: [],
|
|
155
|
+
classified: { changes, verdict: "safe", applicable: true },
|
|
156
|
+
message: commitMessage(changes),
|
|
157
|
+
withheldConstraints: []
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -141,21 +141,36 @@ export class MongoHistoryService {
|
|
|
141
141
|
const collection = this.db.collection("__rebase_history");
|
|
142
142
|
|
|
143
143
|
// 1. Enforce maxEntries
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
144
|
+
//
|
|
145
|
+
// One read decides both WHICH rows go and HOW MANY. It used to be two:
|
|
146
|
+
// countDocuments gave a quantity, and a second find(...).limit(quantity)
|
|
147
|
+
// chose the victims. recordHistory fires this without awaiting it, so a
|
|
148
|
+
// row updated twice in quick succession runs two prunes at once — and
|
|
149
|
+
// the quantity from one snapshot was applied to another. Both observe
|
|
150
|
+
// three rows and decide "drop one"; the first deletes the oldest, the
|
|
151
|
+
// second re-reads, now sees a different oldest, and deletes that too.
|
|
152
|
+
// Two rows go where one should have, and the history falls BELOW
|
|
153
|
+
// maxEntries. Observed as "Expected length: 2, Received length: 1".
|
|
154
|
+
//
|
|
155
|
+
// Sorting newest-first and skipping maxEntries names the survivors by
|
|
156
|
+
// position instead, so every _id returned was genuinely surplus at a
|
|
157
|
+
// single consistent read. A concurrent prune that already removed some
|
|
158
|
+
// of them makes the delete a no-op; a concurrent insert is simply not
|
|
159
|
+
// in this snapshot, and the prune that insert fires deals with it.
|
|
160
|
+
//
|
|
161
|
+
// _id breaks ties on updated_at. Two writes inside the same millisecond
|
|
162
|
+
// are otherwise ordered arbitrarily, and an unstable sort under skip()
|
|
163
|
+
// can hand back a row that is not surplus at all.
|
|
164
|
+
const surplus = await collection
|
|
165
|
+
.find({ entity_id: id,
|
|
150
166
|
table_name: tableName })
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
167
|
+
.sort({ updated_at: -1,
|
|
168
|
+
_id: -1 })
|
|
169
|
+
.skip(this.retention.maxEntries)
|
|
170
|
+
.toArray();
|
|
154
171
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
await collection.deleteMany({ _id: { $in: idsToDelete } });
|
|
158
|
-
}
|
|
172
|
+
if (surplus.length > 0) {
|
|
173
|
+
await collection.deleteMany({ _id: { $in: surplus.map(entry => entry._id) } });
|
|
159
174
|
}
|
|
160
175
|
|
|
161
176
|
// 2. Enforce ttlDays
|