Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for compensating writes #5599

Merged
merged 22 commits into from
Mar 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* None

### Enhancements
* None
* Added a new error class `CompensatingWriteError` which indicates that one or more object changes have been reverted by the server.
This can happen when the client creates/updates objects that do not match any subscription, or performs writes on an object it didn't have permission to access. ([#5599](https://github.com/realm/realm-js/pull/5599))

### Fixed
* <How to hit and notice issue? what was the impact?> ([#????](https://github.com/realm/realm-js/issues/????), since v?.?.?)
Expand Down
89 changes: 87 additions & 2 deletions integration-tests/tests/src/tests/sync/flexible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,19 @@ import {
BSON,
ClientResetMode,
ConfigurationWithSync,
ErrorCallback,
FlexibleSyncConfiguration,
Realm,
SessionStopPolicy,
SyncConfiguration,
CompensatingWriteError,
} from "realm";

import { authenticateUserBefore, importAppBefore, openRealmBeforeEach } from "../../hooks";
import { DogSchema, IPerson, PersonSchema } from "../../schemas/person-and-dog-with-object-ids";
import { DogSchema, IPerson, PersonSchema, IDog } from "../../schemas/person-and-dog-with-object-ids";
import { closeRealm } from "../../utils/close-realm";
import { expectClientResetError } from "../../utils/expect-sync-error";
import { createSyncConfig } from "../../utils/open-realm";
import { createPromiseHandle } from "../../utils/promise-handle";

const FlexiblePersonSchema = { ...PersonSchema, properties: { ...PersonSchema.properties, nonQueryable: "string?" } };

Expand Down Expand Up @@ -396,6 +398,89 @@ describe.skipIf(environment.missingServer, "Flexible sync", function () {
});
});

describe("Sync Errors", () => {
it("compensating writes", async function () {
const objectIds = [new BSON.ObjectId(), new BSON.ObjectId(), new BSON.ObjectId()].sort((a, b) =>
a.toString().localeCompare(b.toString()),
);

const person1Id = objectIds[0];
const person2Id = objectIds[1];
const dogId = objectIds[2];

const callbackHandle = createPromiseHandle();

const errorCallback: ErrorCallback = (_, error) => {
expect(error.code).to.equal(231);
expect(error.isFatal).to.be.false;
expect(error.message).to.contain(
"Client attempted a write that is outside of permissions or query filters; it has been reverted",
);

if (!(error instanceof CompensatingWriteError)) {
throw new Error("Expected a CompensatingWriteError");
}

expect(error.writes.length).to.equal(3);

const compensatingWrites = error.writes.sort((a, b) =>
(a.primaryKey as BSON.ObjectId).toString().localeCompare((b.primaryKey as BSON.ObjectId).toString()),
);

expect((compensatingWrites[0].primaryKey as BSON.ObjectId).equals(person1Id)).to.be.true;
expect((compensatingWrites[1].primaryKey as BSON.ObjectId).equals(person2Id)).to.be.true;
expect((compensatingWrites[2].primaryKey as BSON.ObjectId).equals(dogId)).to.be.true;

expect(compensatingWrites[0].objectName).to.equal(FlexiblePersonSchema.name);
expect(compensatingWrites[1].objectName).to.equal(FlexiblePersonSchema.name);
expect(compensatingWrites[2].objectName).to.equal(DogSchema.name);

expect(compensatingWrites[0].reason).to.contain("object is outside of the current query view");
expect(compensatingWrites[1].reason).to.contain("object is outside of the current query view");
expect(compensatingWrites[2].reason).to.contain("object is outside of the current query view");

callbackHandle.resolve();
};

const realm = await Realm.open({
schema: [FlexiblePersonSchema, DogSchema],
sync: {
flexible: true,
user: this.user,
onError: errorCallback,
},
});

await realm.subscriptions.update((mutableSubs) => {
mutableSubs.add(realm.objects(FlexiblePersonSchema.name).filtered("age < 30"));
mutableSubs.add(realm.objects(DogSchema.name).filtered("age > 5"));
});

realm.write(() => {
//Outside subscriptions
const tom = realm.create<IPerson>(FlexiblePersonSchema.name, {
_id: person1Id,
name: "Tom",
age: 36,
});
realm.create<IPerson>(FlexiblePersonSchema.name, { _id: person2Id, name: "Maria", age: 44 });
realm.create<IDog>(DogSchema.name, { _id: dogId, name: "Puppy", age: 1, owner: tom });

//Inside subscriptions
const luigi = realm.create<IPerson>(FlexiblePersonSchema.name, {
_id: new BSON.ObjectId(),
name: "Luigi",
age: 20,
});
realm.create<IPerson>(FlexiblePersonSchema.name, { _id: new BSON.ObjectId(), name: "Mario", age: 22 });
realm.create<IDog>(DogSchema.name, { _id: new BSON.ObjectId(), name: "Oldy", age: 6, owner: luigi });
});

await realm.syncSession?.uploadAllLocalChanges();
await callbackHandle.promise;
});
});

describe("With realm opened before", function () {
openRealmBeforeEach({
schema: [FlexiblePersonSchema, DogSchema],
Expand Down
2 changes: 1 addition & 1 deletion integration-tests/tests/src/utils/promise-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function createPromiseHandle<T = void>(): PromiseHandle<T> {
reject = arg1;
});
if (!resolve || !reject) {
throw new Error("Expected promise executor to be called synchroniously");
throw new Error("Expected promise executor to be called synchronously");
}
return { promise, resolve, reject };
}
10 changes: 8 additions & 2 deletions packages/bindgen/spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -546,8 +546,7 @@ records:
# server_requests_action:
# type: sync::ProtocolErrorInfo::Action
# default: sync::ProtocolErrorInfo::Action::NoAction
# compensating_writes_info: std::vector<sync::CompensatingWriteErrorInfo>

compensating_writes_info: std::vector<CompensatingWriteErrorInfo>

Request:
cppName: app::Request
Expand Down Expand Up @@ -591,6 +590,13 @@ records:
default_request_timeout_ms: util::Optional<uint64_t>
device_info: DeviceInfo

CompensatingWriteErrorInfo:
cppName: sync::CompensatingWriteErrorInfo
fields:
object_name: std::string
reason: std::string
primary_key: Mixed

opaqueTypes:
- Schema
- Group
Expand Down
13 changes: 12 additions & 1 deletion packages/realm/src/assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
//
////////////////////////////////////////////////////////////////////////////

import { AssertionError, DefaultObject, Realm, TypeAssertionError, binding } from "./internal";
import { AssertionError, BSON, DefaultObject, PrimaryKey, Realm, TypeAssertionError, binding } from "./internal";

/**
* Expects the condition to be truthy
Expand Down Expand Up @@ -76,6 +76,17 @@ assert.symbol = (value: unknown, target?: string): asserts value is symbol => {
assert(typeof value === "symbol", () => new TypeAssertionError("a symbol", value, target));
};

assert.primaryKey = (value: unknown, target?: string): asserts value is PrimaryKey => {
assert(
value === null ||
typeof value === "number" ||
typeof value === "string" ||
value instanceof BSON.UUID ||
value instanceof BSON.ObjectId,
() => new TypeAssertionError("a primary key", value, target),
);
};
papafe marked this conversation as resolved.
Show resolved Hide resolved

assert.object = <K extends string | number | symbol = string, V = unknown>(
value: unknown,
target?: string,
Expand Down
48 changes: 46 additions & 2 deletions packages/realm/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Used by TS docs
ClientResetMode,
Configuration,
PrimaryKey,
assert,
binding,
} from "./internal";

Expand Down Expand Up @@ -92,7 +94,9 @@ export class TimeoutError extends Error {

/** @internal */
export function fromBindingSyncError(error: binding.SyncError) {
if (error.isClientResetRequested) {
if (error.systemError.code === 231) {
return new CompensatingWriteError(error);
} else if (error.isClientResetRequested) {
return new ClientResetError(error);
} else {
return new SyncError(error);
Expand All @@ -117,7 +121,6 @@ export class SyncError extends Error {
}
}

const ORIGINAL_FILE_PATH_KEY = "ORIGINAL_FILE_PATH";
const RECOVERY_FILE_PATH_KEY = "RECOVERY_FILE_PATH";

/**
Expand All @@ -138,3 +141,44 @@ export class ClientResetError extends SyncError {
};
}
}

/**
* An error class that indicates that one or more object changes have been reverted by the server.
papafe marked this conversation as resolved.
Show resolved Hide resolved
* This can happen when the client creates/updates objects that do not match any subscription, or performs writes on
* an object it didn't have permission to access.
*/
export class CompensatingWriteError extends SyncError {
/**
* The array of information about each object that caused the compensating write.
*/
public writes: CompensatingWriteInfo[] = [];
papafe marked this conversation as resolved.
Show resolved Hide resolved

/** @internal */
constructor(error: binding.SyncError) {
super(error);
for (const { objectName, primaryKey, reason } of error.compensatingWritesInfo) {
assert.primaryKey(primaryKey);
this.writes.push({ objectName, reason, primaryKey });
}
}
}

/**
* The details of a compensating write performed by the server.
*/
export type CompensatingWriteInfo = {
/**
* The type of the object that caused the compensating write.
*/
objectName: string;

/**
* The reason for the compensating write.
*/
reason: string;

/**
* The primary key of the object that caused the compensating write.
*/
primaryKey: PrimaryKey;
};
2 changes: 2 additions & 0 deletions packages/realm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export {
CollectionChangeCallback,
CollectionChangeSet,
CollectionPropertyTypeName,
CompensatingWriteError,
CompensatingWriteInfo,
Configuration,
ConfigurationWithoutSync,
ConfigurationWithSync,
Expand Down