File size: 5,387 Bytes
aa89cb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
import { UserContext } from '../src/core/context';
import {
  DelegatingMutationPolicyRegistry,
  MutationPolicyError,
} from '../src/core/mutation-policy';
import {
  BrowserSQLiteDriver,
  BrowserSQLiteTeaQLClient,
  BrowserSQLiteWorkerLike,
} from '../src/sql/browser-sqlite';
import {
  BrowserSQLiteRequest,
  BrowserSQLiteResponse,
} from '../src/sql/browser-sqlite-protocol';

class FakeWorker implements BrowserSQLiteWorkerLike {
  readonly requests: BrowserSQLiteRequest[] = [];
  terminated = false;
  private readonly messageListeners = new Set<(event: MessageEvent<BrowserSQLiteResponse>) => void>();
  private readonly errorListeners = new Set<(event: ErrorEvent) => void>();

  postMessage(message: BrowserSQLiteRequest): void {
    this.requests.push(message);
    queueMicrotask(() => {
      let result: unknown;
      if (message.operation === 'query') {
        const sql = String(message.payload?.sql ?? '');
        result = sql.startsWith('SELECT')
          ? { rows: [{ id: 1, name: 'Local' }], rowCount: 1 }
          : { rows: [], rowCount: 0 };
      }
      const event = { data: {
        type: 'teaql-browser-sqlite-response', requestId: message.requestId, result,
      } } as MessageEvent<BrowserSQLiteResponse>;
      for (const listener of this.messageListeners) listener(event);
    });
  }

  addEventListener(type: 'message' | 'error', listener: any): void {
    if (type === 'message') this.messageListeners.add(listener);
    else this.errorListeners.add(listener);
  }

  removeEventListener(type: 'message' | 'error', listener: any): void {
    if (type === 'message') this.messageListeners.delete(listener);
    else this.errorListeners.delete(listener);
  }

  terminate(): void { this.terminated = true; }
}

describe('BrowserSQLiteDriver', () => {
  test('defaults to a deterministic in-memory database', async () => {
    const worker = new FakeWorker();
    const driver = await BrowserSQLiteDriver.open(worker);

    expect(worker.requests[0]).toMatchObject({
      operation: 'open',
      payload: { storage: 'memory', databaseName: 'teaql-browser.sqlite3' },
    });
    await driver.close();
    expect(worker.terminated).toBe(true);
  });

  test('supports explicit OPFS and buffered stream compatibility', async () => {
    const worker = new FakeWorker();
    const driver = await BrowserSQLiteDriver.open(worker, {
      storage: 'opfs', databaseName: 'demo.sqlite3', terminateWorkerOnClose: false,
    });

    const rows: unknown[] = [];
    for await (const row of driver.stream('SELECT id, name FROM demo')) rows.push(row);
    expect(rows).toEqual([{ id: 1, name: 'Local' }]);
    expect(worker.requests[0].payload).toMatchObject({ storage: 'opfs', databaseName: 'demo.sqlite3' });
    await driver.close();
    expect(worker.terminated).toBe(false);
  });

  test('serializes a transaction and rolls it back on failure', async () => {
    const worker = new FakeWorker();
    const driver = await BrowserSQLiteDriver.open(worker);

    await expect(driver.transaction(async session => {
      await session.query('UPDATE demo SET name = ?', ['Changed']);
      throw new Error('reject mutation');
    })).rejects.toThrow('reject mutation');

    expect(worker.requests.filter(request => request.operation === 'query')
      .map(request => request.payload?.sql)).toEqual([
        'BEGIN IMMEDIATE', 'UPDATE demo SET name = ?', 'ROLLBACK',
      ]);
    await driver.close();
  });

  test('reset invalidates schema state and explicitly reconciles it again', async () => {
    const worker = new FakeWorker();
    const client = await BrowserSQLiteTeaQLClient.open(worker, {});
    const context = new UserContext().insertResource('dataService', client);
    client.setUserContext(context);

    await context.ensureSchema();
    await client.reset(context);

    expect(worker.requests.filter(request => request.operation === 'reset')).toHaveLength(1);
    expect(worker.requests.filter(request =>
      request.operation === 'query' && String(request.payload?.sql).includes('teaql_id_space'),
    )).toHaveLength(2);
    await client.close();
  });

  test('rejects path-like database names', async () => {
    await expect(BrowserSQLiteDriver.open(new FakeWorker(), {
      databaseName: '../outside.sqlite3',
    })).rejects.toThrow('simple file name');
  });

  test.each(['memory', 'opfs'] as const)(
    '%s profile applies Mutation Policy before sending SQL to the worker',
    async storage => {
      const worker = new FakeWorker();
      const client = await BrowserSQLiteTeaQLClient.open(worker, {}, { storage });
      const context = new UserContext().withMutationPolicyRegistry(
        new DelegatingMutationPolicyRegistry(() => ({
          identity: { policyId: 'browser-demo', version: '1', fingerprint: 'sha256:demo' },
          review: () => ({ verdict: 'deny', code: 'DEMO_DENIED', message: 'disabled' }),
        })),
      );
      client.setUserContext(context);
      const queriesBefore = worker.requests.filter(request => request.operation === 'query').length;

      await expect(client.executeMutation({
        entity: 'Demo', action: 'Create', payload: { name: 'blocked' }, comment: 'demo',
      })).rejects.toThrow(MutationPolicyError);
      expect(worker.requests.filter(request => request.operation === 'query')).toHaveLength(queriesBefore);
      await client.close();
    },
  );
});