test: make the device sync fakes as strict as Android and Stalwart #34

The fake providers and the fake server were more lenient than the
real ones, so three bugs showed only on a device: an event inserted
without a status, small contact photos, and meetings too big for one
provider transaction.

The fake CalendarProvider leaves STATUS NULL when an app writes none.
The fake ContactsProvider keeps a photo within the 96 px thumbnail as
the thumbnail alone, without a PHOTO_FILE_ID, and serves it to
readPhoto from there. A batch fails with tooLarge when it weighs more
than one Binder transaction, a photo counted by its bytes. The fake
server drops empty maps inside recurrence overrides, as Stalwart does.
Tests now read photos the way the engine does, and the helpers that
made up for the old fakes are gone.
This commit is contained in:
Linus Rath
2026-09-27 10:20:54 +02:00
parent 3ab131dbe8
commit 117823f746
9 changed files with 148 additions and 77 deletions
@@ -5,9 +5,8 @@ import { describe, expect, it } from 'vitest';
import { Attendees, Events } from '../../android-columns';
import { calendarPlanner } from '../../calendar/planner';
import { contactsPlanner } from '../../contacts/planner';
import { estimateBatchBytes } from '../../engine/batch';
import type { OpGroup } from '../../planner';
import { CALENDAR_AUTHORITY, type ProviderPort } from '../../types';
import { CALENDAR_AUTHORITY } from '../../types';
import type { CalendarEventWire } from '../../wire';
import { createHarness, rowWrites, type Harness as EngineHarness } from '../engine/harness';
import { Harness, ME, serverApply } from './harness';
@@ -47,23 +46,10 @@ function meeting(attendees: number, overrides: number, me = ME, calendarId = 'b'
} as unknown as CalendarEventWire;
}
/** The engine with the real planners, its provider refusing a batch bigger than one Binder transaction (as Android does). */
function engineWithBinderLimit(): EngineHarness {
/** The engine with the real planners (the fake provider refuses a batch over one Binder transaction, as Android does). */
function engineWithRealPlanners(): EngineHarness {
const h = createHarness();
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
const base = h.deps.provider;
h.deps.provider = (name, authority): ProviderPort => {
const port = base(name, authority);
return {
accountName: port.accountName,
authority: port.authority,
query: (q) => port.query(q),
readSyncState: () => port.readSyncState(),
readPhoto: (id, px) => port.readPhoto(id, px),
applyBatch: async (ops) =>
estimateBatchBytes(ops) > TRANSACTION ? { ok: false, reason: 'tooLarge', message: 'TransactionTooLargeException' } : port.applyBatch(ops),
};
};
return h;
}
@@ -204,7 +190,7 @@ describe('device sync engine: big meetings', () => {
[25, 52],
[100, 12],
])('downloads a meeting of %i attendees on %i overrides in several transactions', async (attendees, overrides) => {
const h = engineWithBinderLimit();
const h = engineWithRealPlanners();
const { id: _drop, ...event } = meeting(attendees, overrides, 'alice@example.com', h.calendar) as unknown as Record<string, unknown>;
const id = h.server.addEvent('a', event);
@@ -219,7 +205,7 @@ describe('device sync engine: big meetings', () => {
it('finishes a big meeting cut off between its groups by a crash, without duplicating rows', async () => {
const setup = () => {
const h = engineWithBinderLimit();
const h = engineWithRealPlanners();
const { id: _drop, ...event } = meeting(25, 52, 'alice@example.com', h.calendar) as unknown as Record<string, unknown>;
return { h, id: h.server.addEvent('a', event) };
};
@@ -52,7 +52,9 @@ describe('contacts download: golden rows', () => {
expect(cells(rows['relatedTo:urn:uuid:other-person'], TYPED)).toEqual({ data1: 'Other Person', data2: '6', data3: null });
expect(cells(rows['notes:n1'], ['data1'])).toEqual({ data1: 'first note' });
expect(rows['media:ph'].data_sync2).toBe(photoHash(JPEG));
expect(h.device.photo(Number(rows['media:ph'].data14))).toBe(JPEG);
// A 1×1 photo is kept as the thumbnail alone (no PHOTO_FILE_ID), and read from it.
expect(rows['media:ph'].data14).toBeNull();
expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG);
const raw = h.rawContact(rawId);
expect(raw).toMatchObject({
+3 -41
View File
@@ -4,7 +4,7 @@
* way the engine applies them (one op group per batch); uploads go through
* the fake server's patch handling and come back through its normalisation.
*/
import { Data, GroupMembership, Groups, RawContacts } from '../../android-columns';
import { GroupMembership, Groups, RawContacts } from '../../android-columns';
import { makeKeyMinter, parseObjectRef, uuidFrom } from '../../common/ids';
import { contactsPlanner } from '../../contacts/planner';
import type {
@@ -16,7 +16,7 @@ import type {
UploadAction,
UploadPlan,
} from '../../planner';
import { CONTACTS_AUTHORITY, type BatchResult, type OpResult, type ProviderOp, type ProviderPort, type Row } from '../../types';
import { CONTACTS_AUTHORITY, type BatchResult, type ProviderPort, type Row } from '../../types';
import { FakeJmapServer } from '../fakes/fake-jmap-server';
import { FakeDeviceProviders } from '../fakes/fake-provider';
@@ -34,40 +34,6 @@ function rowsOf(res: { columns: string[]; rows: unknown[][] }): Row[] {
return res.rows.map((cells) => Object.fromEntries(res.columns.map((c, i) => [c, cells[i]])) as Row);
}
/**
* ContactsProvider keeps a photo no larger than its 96 px thumbnail as the
* thumbnail only: PHOTO_FILE_ID (DATA14) stays NULL, and the thumbnail
* (DATA15) does not read back. The fake stores every photo with a display
* photo; this makes the photo rows a batch wrote thumbnail-only, as a device
* does with the test fixtures' 1×1 JPEGs.
*/
export function keepThumbnailsOnly(device: FakeDeviceProviders, ops: ProviderOp[], results: OpResult[]): void {
ops.forEach((op, i) => {
if ((op.op !== 'insert' && op.op !== 'update') || op.table !== 'data') return;
const bytes = op.values[Data.DATA15];
if (!bytes || typeof bytes !== 'object') return;
const id = op.op === 'insert' ? results[i]?.id : op.id;
const row = id === undefined ? undefined : device.table('data').get(id);
if (row) row[Data.DATA14] = null;
});
}
/** A provider port whose photo writes come out thumbnail-only (see `keepThumbnailsOnly`). */
export function thumbnailOnlyPort(device: FakeDeviceProviders, port: ProviderPort): ProviderPort {
return {
accountName: port.accountName,
authority: port.authority,
query: (q) => port.query(q),
readSyncState: () => port.readSyncState(),
readPhoto: (id, px) => port.readPhoto(id, px),
applyBatch: async (ops) => {
const res = await port.applyBatch(ops);
if (res.ok) keepThumbnailsOnly(device, ops, res.results);
return res;
},
};
}
export class Harness {
readonly device = new FakeDeviceProviders();
readonly port: ProviderPort = this.device.port(ACCOUNT, CONTACTS_AUTHORITY);
@@ -82,8 +48,6 @@ export class Harness {
readonly unselected = new Set<string>();
/** When false the context has no `groupsOf` (memberships not synced). */
groupIndex = true;
/** When true the photos the planner writes come out thumbnail-only (see `keepThumbnailsOnly`). */
thumbnailPhotos = false;
readonly ctx: ContactsContext;
constructor() {
@@ -133,9 +97,7 @@ export class Harness {
async apply(group: OpGroup): Promise<BatchResult> {
if (!group.ops.length) return { ok: true, results: [] };
const res = await this.port.applyBatch(group.ops);
if (res.ok && this.thumbnailPhotos) keepThumbnailsOnly(this.device, group.ops, res.results);
return res;
return this.port.applyBatch(group.ops);
}
async applyOk(group: OpGroup): Promise<void> {
@@ -80,8 +80,7 @@ describe('contacts lifecycle', () => {
const h = new Harness();
h.blobs.set('B1', JPEG);
const { id, rawId } = await h.seed({ name: { full: 'Blob Photo' }, media: { p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } } });
const photo = h.rowsByKey(rawId)['media:p'];
expect(h.device.photo(Number(photo.data14))).toBe(JPEG);
expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG);
expect((await h.contact(rawId)).shadow?.media).toEqual({ p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } });
await expectSettled(h, id, rawId);
});
@@ -164,7 +163,7 @@ describe('contacts lifecycle', () => {
h.blobs.set('B1', JPEG);
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/e1/address': 'c@example.org' });
await h.download(id);
expect(h.device.photo(Number(h.rowsByKey(rawId)['media:p'].data14))).toBe(JPEG);
expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG);
await expectSettled(h, id, rawId);
});
@@ -188,7 +187,6 @@ describe('contacts lifecycle', () => {
it('keeps one row through downloads and merges, and never reads it as deleted', async () => {
const h = new Harness();
h.thumbnailPhotos = true;
const { id, rawId } = await h.seed(small());
expect(photos(h, rawId)).toMatchObject([{ data14: null, data_sync1: 'media:ph' }]);
await expectSettled(h, id, rawId);
@@ -220,7 +218,6 @@ describe('contacts lifecycle', () => {
it('heals the photo rows the same photo was inserted as again, keeping one and the server photo', async () => {
const h = new Harness();
h.thumbnailPhotos = true;
const { id, rawId } = await h.seed(small());
const [row] = photos(h, rawId);
const again = { [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: JPEG }, data_sync1: row.data_sync1, data_sync2: row.data_sync2, data_sync3: row.data_sync3 };
@@ -9,7 +9,6 @@ import { contactsPlanner } from '../../contacts/planner';
import { addServerCards, ANDROID_ACCOUNT, CONTACTS_AUTHORITY, createHarness, rowWrites, type Harness } from '../engine/harness';
import type { SetTarget } from '../fakes/fake-jmap-server';
import { JPEG } from './fixtures';
import { thumbnailOnlyPort } from './harness';
function real(options: Parameters<typeof createHarness>[0] = {}): Harness {
const h = createHarness(options);
@@ -119,8 +118,6 @@ describe('contacts through the engine', () => {
it('keeps one row for a small photo the provider stores as its thumbnail, and never deletes it on the server', async () => {
const h = real();
const base = h.deps.provider;
h.deps.provider = (name, authority) => thumbnailOnlyPort(h.device, base(name, authority));
const media = { ph: { kind: 'photo', uri: `data:image/jpeg;base64,${JPEG}`, mediaType: 'image/jpeg' } };
const card = h.server.addCard('a', { uid: 'u-small', name: { full: 'Photo Smalltest' }, notes: { n1: { note: 'v1' } }, addressBookIds: { [h.book]: true }, media });
expect((await h.run()).outcome).toBe('ok');
@@ -429,6 +429,29 @@ describe('FakeJmapServer', () => {
expect(server.get('CalendarEvent', 'a', id)?.updated).toBe('2026-09-27T10:02:00Z');
});
it('drops empty maps inside overrides too, which an override VEVENT cannot hold', async () => {
const { server, port, cal } = setup();
const id = server.addEvent('a', {
title: 'Standup',
start: '2026-09-28T09:00:00',
recurrenceRule: { frequency: 'daily' },
alerts: { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } },
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Late', alerts: { a2: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } } } },
calendarIds: { [cal]: true },
});
await call(port, 'CalendarEvent/set', {
accountId: 'a',
update: { [id]: { 'recurrenceOverrides/2026-09-29T09:00:00/alerts': {}, 'recurrenceOverrides/2026-09-29T09:00:00/locations': {} } },
});
// Read back without them, so the occurrence shows the series' alerts again, as on Stalwart.
const override = (server.get('CalendarEvent', 'a', id)?.recurrenceOverrides as Record<string, Record<string, unknown>>)['2026-09-29T09:00:00'];
expect(override).toMatchObject({ title: 'Late' });
expect(override).not.toHaveProperty('alerts');
expect(override).not.toHaveProperty('locations');
});
it('leaves useDefaultAlerts out of properties: null and returns it when listed', async () => {
const { server, port, cal } = setup();
const id = server.addEvent('a', { title: 'x', useDefaultAlerts: true, calendarIds: { [cal]: true } });
@@ -10,6 +10,7 @@ import {
StructuredPostal,
} from '../android-columns';
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type ProviderOp } from '../types';
import { JPEG } from './contacts/fixtures';
import { FakeDeviceProviders, aospDurationValid, aospRruleValid } from './fakes/fake-provider';
import { compileWhere } from './fakes/fake-sql';
@@ -129,13 +130,13 @@ describe('FakeDeviceProviders: contacts', () => {
});
it('refuses 500 contacts ops without a yield point and oversized batches', async () => {
const fake = new FakeDeviceProviders({ maxBatchBytes: 50_000 });
const fake = new FakeDeviceProviders({ maxBatchBytes: 500_000 });
const port = fake.port(ME, CONTACTS_AUTHORITY);
const ops: ProviderOp[] = Array.from({ length: 500 }, () => ({ op: 'insert', table: 'raw_contacts', values: {} }) as ProviderOp);
expect(await port.applyBatch(ops)).toMatchObject({ ok: false, reason: 'provider' });
ops[250] = { ...ops[250], yieldAllowed: true };
expect((await port.applyBatch(ops)).ok).toBe(true);
const big = await port.applyBatch([{ op: 'insert', table: 'raw_contacts', values: { [RawContacts.SYNC2]: 'x'.repeat(60_000) } }]);
const big = await port.applyBatch([{ op: 'insert', table: 'raw_contacts', values: { [RawContacts.SYNC2]: 'x'.repeat(300_000) } }]);
expect(big).toMatchObject({ ok: false, reason: 'tooLarge' });
});
@@ -166,6 +167,31 @@ describe('FakeDeviceProviders: contacts', () => {
expect(rows.rows[0]).toEqual([String(photo!.fileId), null]);
});
it('keeps a photo within the 96 px thumbnail as the thumbnail alone, and serves that to readPhoto', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId } = await contactWithEmail(fake);
const res = await port.applyBatch([
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: JPEG } } },
]);
expect(res.ok).toBe(true);
const rows = await port.query({ table: 'data', columns: [Data.DATA14, Data.DATA15], where: `${Data.MIMETYPE} = ?`, args: [MimeType.PHOTO] });
// No PHOTO_FILE_ID for a 1×1 JPEG, and the thumbnail does not read back.
expect(rows.rows[0]).toEqual([null, null]);
expect(await port.readPhoto(rawId, 512)).toEqual({ jpegBase64: JPEG, fileId: null });
});
it('weighs a batch as its Binder transaction does: two bytes per character of its JSON, a photo by its bytes', async () => {
const fake = new FakeDeviceProviders();
const { port, rawId } = await contactWithEmail(fake);
// 600,000 characters of text are about 1.2 MB in the Parcel; the same number of base64 characters are 450 KB of photo.
const text = await port.applyBatch([{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC2]: 'x'.repeat(600_000) } }]);
expect(text).toMatchObject({ ok: false, reason: 'tooLarge' });
const photo = await port.applyBatch([
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: 'A'.repeat(600_000) } } },
]);
expect(photo.ok).toBe(true);
});
it('models Fossify saves: rows re-inserted without DATA_SYNC', async () => {
const fake = new FakeDeviceProviders();
const { rawId, dataId } = await contactWithEmail(fake);
@@ -246,6 +272,15 @@ describe('FakeDeviceProviders: calendar', () => {
expect(fake.row('events', exceptionId)).toBeDefined();
});
it('leaves STATUS NULL on an event an app inserts without one (the column has no default)', async () => {
const fake = new FakeDeviceProviders();
const { calendarId } = await calendar(fake);
const id = fake.user.insertEvent(calendarId, { [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.TITLE]: 'x' });
expect(fake.row('events', id)![Events.STATUS]).toBeNull();
// Unlike AVAILABILITY and ACCESS_LEVEL (NOT NULL DEFAULT 0).
expect(fake.row('events', id)).toMatchObject({ [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0 });
});
it('hard-deletes app-deleted events without _SYNC_ID and soft-deletes synced ones', async () => {
const fake = new FakeDeviceProviders();
const { calendarId } = await calendar(fake);
@@ -965,6 +965,8 @@ function normalizeEvent(input: Obj, now: string, mode: 'jmap' | 'server'): Obj {
}
privacy = Math.max(privacy, PRIVACY_RANK.indexOf(String(value.privacy)));
for (const forbidden of FORBIDDEN_OVERRIDE_PROPERTIES) delete value[forbidden];
// Stored as a VEVENT of its own: an empty map writes nothing there either.
dropEmptyMaps(value, EVENT_MAPS);
if (Object.keys(value).length > 0) {
normalizeEventTimes(value);
addNestedTypes(value);
@@ -25,6 +25,12 @@
* owner, all-day times are zeroed in UTC and `P<n>S` all-day durations become
* `P<n>D`, while `PT0S`-style all-day durations, NULL STATUS updates,
* SELF_ATTENDEE_STATUS updates and RRULEs with unknown parts fail the batch.
* STATUS has no default: an event an app inserts without one holds NULL.
* - Photos, as PhotoProcessor keeps them: one within the 96 px thumbnail is
* the thumbnail alone (PHOTO_FILE_ID NULL), a bigger one (or bytes that are
* no JPEG or PNG) a display photo with a file id; DATA15 never reads back.
* - A batch over one Binder transaction (`estimateBatchBytes` over 1 MB, by
* default) fails with `tooLarge`.
* - Scoping: rows of other accounts are invisible and untouchable.
* - The native bridge's rules (android/.../sync/ProviderOps.kt and
* ProviderScope.kt): an op with `id` expects exactly one row unless it says
@@ -74,6 +80,7 @@ import {
type WriteCell,
type WriteRow,
} from '../../types';
import { estimateBatchBytes } from '../../engine/batch';
import { compileOrderBy, compileWhere, whereColumns } from './fake-sql';
export const FAKE_ACCOUNT_TYPE = 'com.anonymous.bulwarkmobile.account';
@@ -240,7 +247,7 @@ export interface FakeProviderOptions {
accountType?: string;
/** Derive/split names and addresses like ContactsProvider (default true). */
normalize?: boolean;
/** Byte budget of one applyBatch before it fails with `tooLarge` (default 1 MB). */
/** Bytes of one applyBatch, as `estimateBatchBytes` weighs them, before it fails with `tooLarge` (default 1 MB). */
maxBatchBytes?: number;
}
@@ -517,7 +524,7 @@ export class FakeDeviceProviders {
record(false);
return { ok: false, reason, message };
}
if (JSON.stringify(ops).length > this.maxBatchBytes) {
if (parcelBytes(ops) > this.maxBatchBytes) {
record(false);
return { ok: false, reason: 'tooLarge', message: 'TransactionTooLargeException' };
}
@@ -569,6 +576,26 @@ export class FakeDeviceProviders {
}
}
/**
* What a batch weighs in its Binder transaction: `estimateBatchBytes`, with a
* photo blob counted by its bytes (the native side hands ContentValues a
* decoded `byte[]`, not the base64 text).
*/
function parcelBytes(ops: readonly ProviderOp[]): number {
let blobs = 0;
const texts = ops.map((op) => {
if (op.op !== 'insert' && op.op !== 'update') return op;
const values = { ...op.values };
for (const [column, value] of Object.entries(values)) {
if (value === null || typeof value !== 'object') continue;
blobs += Math.floor((value.b64.length * 3) / 4);
values[column] = null;
}
return { ...op, values };
});
return estimateBatchBytes(texts) + blobs;
}
function cloneTables(tables: Tables): Tables {
const out = {} as Tables;
for (const [name, rows] of Object.entries(tables) as Array<[ProviderTable, RowMap]>) {
@@ -642,8 +669,11 @@ class FakePort implements ProviderPort {
}
for (const row of this.fake.table('data').values()) {
if (row[Data.RAW_CONTACT_ID] === rawContactId && row[Data.MIMETYPE] === MimeType.PHOTO) {
const fileId = row[Data.DATA14] === null ? null : Number(row[Data.DATA14]);
const b64 = fileId === null ? undefined : this.fake.photo(fileId);
const fileId = row[Data.DATA14] === null || row[Data.DATA14] === undefined ? null : Number(row[Data.DATA14]);
// A photo kept as its thumbnail alone is read from the thumbnail.
const thumbnail = row[Data.DATA15];
if (fileId === null) return typeof thumbnail === 'string' && thumbnail.startsWith('blob:') ? { jpegBase64: thumbnail.slice(5), fileId: null } : null;
const b64 = this.fake.photo(fileId);
return b64 === undefined ? null : { jpegBase64: b64, fileId };
}
}
@@ -755,10 +785,19 @@ class BatchApplier {
return out;
}
/** A photo row's full-size bytes become a stored display photo plus a file id, like PhotoProcessor. */
/**
* A photo row's full-size bytes, as PhotoProcessor keeps them: within the
* 96 px thumbnail as the thumbnail alone (PHOTO_FILE_ID NULL, the bytes kept
* for readPhoto), else as a stored display photo plus a file id.
*/
private processPhoto(row: StoredRow): void {
const v = row[Data.DATA15];
if (typeof v === 'string' && v.startsWith('blob:')) {
const size = imageSize(v.slice(5));
if (size && Math.max(size.width, size.height) <= THUMBNAIL_PX) {
row[Data.DATA14] = null;
return;
}
row[Data.DATA14] = this.fake.storePhoto(v.slice(5));
row[Data.DATA15] = 'thumbnail';
}
@@ -996,6 +1035,34 @@ class BatchApplier {
const defaults = (d: StoredRow): StoredRow => ({ ...d });
/** ContactsProvider's thumbnail size: a photo no bigger is kept as the thumbnail alone. */
const THUMBNAIL_PX = 96;
/** The pixel size of JPEG (its SOFn segment) or PNG (its IHDR chunk) bytes; null for anything else. */
function imageSize(b64: string): { width: number; height: number } | null {
let bytes: Uint8Array;
try {
bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
} catch {
return null;
}
const u16 = (i: number) => (bytes[i] << 8) | bytes[i + 1];
const u32 = (i: number) => ((bytes[i] << 24) >>> 0) + (bytes[i + 1] << 16) + (bytes[i + 2] << 8) + bytes[i + 3];
if (bytes.length >= 24 && bytes[0] === 0x89 && String.fromCharCode(...bytes.slice(12, 16)) === 'IHDR') {
return { width: u32(16), height: u32(20) };
}
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
for (let i = 2; i + 9 < bytes.length; ) {
if (bytes[i] !== 0xff) return null;
const marker = bytes[i + 1];
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
return { height: u16(i + 5), width: u16(i + 7) };
}
i += 2 + u16(i + 2);
}
return null;
}
const RAW_CONTACT_DEFAULTS: StoredRow = {
[RawContacts.SOURCE_ID]: null,
[RawContacts.VERSION]: 1,
@@ -1045,7 +1112,7 @@ const EVENT_DEFAULTS: StoredRow = {
[Events.GUESTS_CAN_INVITE_OTHERS]: 1,
[Events.GUESTS_CAN_SEE_GUESTS]: 1,
[Events.ALL_DAY]: 0,
[Events.STATUS]: 0,
// No default for STATUS (`eventStatus INTEGER`): an app that writes none leaves NULL.
};
/**